From 11218753ce216855293bef6801d6488915f1794d Mon Sep 17 00:00:00 2001 From: Brandon Walter <51866976+echoparkbaby@users.noreply.github.com> Date: Sat, 2 May 2026 18:15:57 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20secret=20handling=20=E2=80=94=20status?= =?UTF-8?q?=20badges=20+=20redacted=20endpoint=20+=20rotation=20audit=20(1?= =?UTF-8?q?.0.0-26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #5 of the post-Codex hardening list: * Settings UI now shows a `[set]` (green) or `[unset]` (gray) badge next to every password/key field. Tells the operator at a glance which secrets are configured without ever rendering the value. * SSH key gets a granular source label: `set (environment variable)`, `set (mounted secret)`, or `set (stored in settings DB — prefer a mounted secret in production)`. Same hint copy in the field's help text now actively recommends `/run/secrets/ssh_key` over the textarea. * New `GET /api/v1/settings/redacted` admin-only endpoint dumps every editable setting with secrets replaced by `***`, plus the per-secret status map. Useful for ops triage ("what's actually loaded?") without the secrets ever leaving the container or hitting a transcript. * `POST /api/v1/settings` writes a `settings_secret_changed` audit event whenever a non-empty secret is rotated. Records field names, operator, source IP — never the value. Lets the audit page answer "who rotated the SMTP password last week?". Internal: `_SECRET_FIELDS` constant in routes.py is now the single source of truth for which fields get the redaction / audit treatment. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config.py | 2 +- app/routes.py | 88 ++++++++++++++++++++++++++++++++++--- app/static/app.css | 25 +++++++++++ app/templates/settings.html | 12 ++--- 4 files changed, 115 insertions(+), 12 deletions(-) diff --git a/app/config.py b/app/config.py index 06f942c..2a4c099 100644 --- a/app/config.py +++ b/app/config.py @@ -83,7 +83,7 @@ class Settings(BaseSettings): ssh_key: str = "" # PEM private key content (paste full key including headers) # Application version — used by the /api/v1/updates/check endpoint - app_version: str = "1.0.0-25" + app_version: str = "1.0.0-26" # ---- Authentication (1.0.0-22) ---- # session_secret: HMAC key for signing session cookies. Empty = generate diff --git a/app/routes.py b/app/routes.py index e9996c4..87ecce4 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1280,6 +1280,7 @@ async def settings_page( return templates.TemplateResponse(request, "settings.html", { "request": request, "editable": editable, + "secret_status": _secret_status(), "smtp_enabled": bool(settings.smtp_host), "ssh_configured": _ssh.is_configured(), "app_version": settings.app_version, @@ -1288,20 +1289,95 @@ async def settings_page( }) +# Field names that hold secrets and must never be rendered to the UI or +# included in the redacted-settings dump verbatim. +_SECRET_FIELDS = ("smtp_password", "ssh_password", "ssh_key", "truenas_api_key") + + +def _secret_status() -> dict[str, str]: + """Per-secret display string for the settings page so the operator can + see whether each secret is configured (and how) without ever rendering + the value. Distinguishes env-var, mounted-file, and DB-stored sources + for ssh_key — the others can only come from the live settings object.""" + import os as _os + from app.ssh_client import _MOUNTED_KEY_PATH + + def _has(field: str) -> bool: + v = getattr(settings, field, "") + return bool(v) + + # ssh_key gets the most granular treatment because we actively prefer + # the mounted file path in production but the textarea is still wired. + if _os.environ.get("SSH_KEY"): + ssh_key_status = "set (environment variable)" + elif _has("ssh_key"): + ssh_key_status = "set (stored in settings DB — prefer a mounted secret in production)" + elif _os.path.exists( + _os.environ.get("SSH_KEY_FILE", _MOUNTED_KEY_PATH) + ): + ssh_key_status = "set (mounted secret)" + else: + ssh_key_status = "unset" + + return { + "smtp_password": "set" if _has("smtp_password") else "unset", + "ssh_password": "set" if _has("ssh_password") else "unset", + "ssh_key": ssh_key_status, + "truenas_api_key": "set" if _has("truenas_api_key") else "unset", + } + + +@router.get("/api/v1/settings/redacted") +async def get_settings_redacted(request: Request): + """Admin-only diagnostic dump of every editable setting with secrets + replaced by '***'. Useful for ops triage ("what's actually loaded + right now?") without leaking the real values into the transcript.""" + user = request.state.current_user + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + if not user.is_admin: + raise HTTPException(status_code=403, detail="Admin only") + out: dict[str, object] = {} + for field in settings_store._EDITABLE.keys(): + val = getattr(settings, field, None) + if field in _SECRET_FIELDS: + out[field] = "***" if val else None + else: + out[field] = val + out["_secret_status"] = _secret_status() + return out + + @router.post("/api/v1/settings") -async def save_settings(body: dict): +async def save_settings(request: Request, body: dict): """Save editable runtime settings. Secrets are only updated if non-empty.""" - # Don't overwrite secrets if client sent empty string - for secret_field in ("smtp_password", "truenas_api_key", "ssh_password", "ssh_key"): - if secret_field in body and body[secret_field] == "": - del body[secret_field] + user = request.state.current_user + # Don't overwrite secrets if client sent empty string. Track which + # ones DID get a real change so we can audit the rotation. + rotated: list[str] = [] + for secret_field in _SECRET_FIELDS: + if secret_field in body: + if body[secret_field] == "": + del body[secret_field] + else: + rotated.append(secret_field) try: saved = settings_store.save(body) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) - return {"saved": True, "keys": saved} + # Audit secret rotations — never log the value, only the field name + + # operator + source IP. Lets `audit` page answer "who rotated the + # SMTP password last week?" + if rotated and user: + await auth.audit_auth_event( + "settings_secret_changed", + user.username, + f"Rotated secrets from {_client_ip(request)}: {', '.join(sorted(rotated))}", + ) + + return {"saved": True, "keys": saved, "rotated_secrets": rotated} @router.post("/api/v1/settings/test-smtp") diff --git a/app/static/app.css b/app/static/app.css index f1b2fb1..03539c2 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -2426,6 +2426,31 @@ tr.drawer-row-active { color: var(--yellow); } +/* ----------------------------------------------------------------------- + Settings: secret-status pills next to password/key labels +----------------------------------------------------------------------- */ +.secret-status { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + border-radius: 3px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.secret-status.secret-set { + background: color-mix(in srgb, var(--green, #39c179) 14%, transparent); + color: var(--green, #39c179); + border: 1px solid color-mix(in srgb, var(--green, #39c179) 35%, transparent); +} +.secret-status.secret-unset { + background: color-mix(in srgb, var(--text-muted) 14%, transparent); + color: var(--text-muted); + border: 1px solid color-mix(in srgb, var(--text-muted) 35%, transparent); +} + /* ----------------------------------------------------------------------- Login screen ----------------------------------------------------------------------- */ diff --git a/app/templates/settings.html b/app/templates/settings.html index 17671aa..88b5f28 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -61,7 +61,7 @@ - + @@ -125,17 +125,19 @@ - + - +
Either password or key auth. Key takes precedence if both are set. - Key is stored securely in /data/settings_overrides.json. + For production, mount the key as a Docker secret at + /run/secrets/ssh_key instead of pasting it here + — that path is checked automatically when no key is in settings.
@@ -290,7 +292,7 @@ - +