"""The admin Settings panel's read/write surface (phase 168, task 04). ``TODO.md`` L3: "…can edit anything in .env and save it to the database. The database value should override the env var. The change should take effect on clicking 'save'." The whole router sits behind :func:`app.core.auth.require_admin` (router-wide ``dependencies`` — the :mod:`app.api.ui_settings` pattern): anonymous callers AND token users get 403 on every route (only the admin retunes the deployment). Routes (under ``/api`` via the ``main`` registration): * ``GET /api/admin/settings`` — the whole catalogue: one item per ``Settings`` field, in ``GROUPS`` order (the stable order the E2E pins against), each with its effective value in env-native string form, a ``db``/``env``/``default`` source badge, and the three capability flags. Reads only; creates nothing. * ``PUT /api/admin/settings/{key}`` — set one value (the body's string is stored as-is in ``app_settings``). Fail loud BEFORE the write (LOCKED A5): the FULL candidate set (every overridable stored row + this key/value, layered over the env base) is run through the real ``Settings`` validators — a bad shape OR a cross-field rule (e.g. ``lexical_support_floor`` above a DB-overridden ``relevance_threshold``) is a 422 that names the field and stores NOTHING. Unknown key → 404; a non-overridable key (LOCKED A3) → 422 naming the env var; a ``docs_repo`` value with embedded credentials (``://user:pass@``, the phase-173 leak class) → 422 pointing at the ``docs_user`` / ``docs_token`` fields (C4); a blank value for a secret → 422 (an empty API key / session secret is a self-lockout; ``DELETE`` is the explicit clear). A successful write commits, calls ``invalidate_settings()`` (task 03 makes it live — no restart) and returns the refreshed item. * ``DELETE /api/admin/settings/{key}`` — reset to env: deletes the row (idempotent — deleting an absent key is a success, not a 404), invalidates, returns the item with ``source="env"``/``"default"``. * ``DELETE /api/admin/settings`` — "Clear all overrides": deletes every row, invalidates, ``{"cleared": rowcount}``. Three invariants (the unit tests pin them): 1. **Validate-before-store** — the write path never persists a value the ``Settings`` validators reject (A5); 2. **Masked secrets** (LOCKED A4) — a ``secret`` field reports ``masked``/``has_value`` only, never the plaintext, in no response body; every write logs one line carrying the key and the action, NEVER the value (AGENTS rule 10); 3. **Never write ``.env``** (LOCKED A1) — this module contains no file writes, never shells out and never ``eval``s a value; the env file stays the base, the DB row wins, the reset deletes the row. """ from __future__ import annotations import json import logging import os import re from typing import Any, Literal, get_origin from fastapi import APIRouter, Depends, HTTPException from pydantic import ValidationError from sqlalchemy import delete, func, text from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session from app.config import Settings, get_settings, invalidate_settings from app.core.auth import require_admin from app.core.settings_catalog import CATALOG, GROUPS, env_var from app.core.settings_overlay import LIST_FIELDS, _coerce, enabled from app.db import get_db from app.models import AppSetting from app.schemas import ( AdminSettingIn, AdminSettingItem, AdminSettingsCleared, AdminSettingsOut, AdminSettingsTotals, ) logger = logging.getLogger("app.api.admin_settings") router = APIRouter( prefix="/admin/settings", tags=["admin-settings"], dependencies=[Depends(require_admin)], # phase 168: the settings panel is admin-only ) def _field_type(key: str) -> Literal["int", "float", "bool", "str", "json"]: """The panel control kind for one field, from its annotation (``bool`` before ``int`` — ``isinstance(True, int)`` is ``True``; the one ``list[str]`` field is ``json``, K2).""" annotation = Settings.model_fields[key].annotation if get_origin(annotation) in (list, tuple): return "json" if annotation is bool: return "bool" if annotation is int: return "int" if annotation is float: return "float" return "str" def _display(field_name: str, value: Any) -> str: """Stringify one effective value in the env-native string form (D1: stored strings, one coercion place — the router's display is the mirror of ``settings_overlay._coerce``): bool → ``"true"``/``"false"``, the one ``list[str]`` field → a JSON array (what the env spelling would carry, K2), everything else ``str()``.""" if isinstance(value, bool): return "true" if value else "false" if field_name in LIST_FIELDS: return json.dumps(value) return str(value) def _rows(db: Session) -> dict[str, str]: """``SELECT key, value FROM app_settings`` — the whole table in one round trip (the ``ui_settings`` "display fact, never a query surface" note).""" return {key: value for key, value in db.execute(text("SELECT key, value FROM app_settings"))} def _item( key: str, rows: dict[str, str], effective: Settings, ) -> AdminSettingItem: """One catalogue entry for one field (the stable builder for all three read paths — GET, the PUT/DELETE returns, and the error paths' neighbours).""" meta = CATALOG[key] value = getattr(effective, key) #: Only an OVERRIDABLE row counts as an override: a hand-inserted #: ``database_url`` / ``embedding_dim`` row is never in effect #: (the overlay drops it, LOCKED A3) — it is not an override. has_override = meta.overridable and key in rows #: ``source="db"`` means "in effect": a row the overlay actually #: applies. With the kill switch off, no row is in effect, so the #: badge falls back to env/default (the panel's ``overlay_enabled`` #: banner explains the rest — D6). in_effect = has_override and enabled() if in_effect: source: str = "db" elif os.environ.get(env_var(key)) is not None: source = "env" else: source = "default" if meta.secret: # LOCKED A4 — write-only: the plaintext appears in NO response; # the read side reports has_value (is a non-empty value in # effect at all) and nothing else about it. return AdminSettingItem( key=key, env_var=env_var(key), group=meta.group, label=meta.label, type=_field_type(key), secret=True, restart_required=meta.restart_required, overridable=meta.overridable, source=source, value=None, masked=True, has_value=bool(value), has_override=has_override, db_value=None, default="", ) return AdminSettingItem( key=key, env_var=env_var(key), group=meta.group, label=meta.label, type=_field_type(key), secret=False, restart_required=meta.restart_required, overridable=meta.overridable, source=source, value=_display(key, value), masked=False, has_value=bool(_display(key, value)), has_override=has_override, db_value=rows.get(key) if has_override else None, default=_display(key, Settings.model_fields[key].default), ) def _validate_candidate(candidate: dict[str, str]) -> None: """Validate the FULL candidate set before anything is stored (LOCKED A5, D5): every overridable stored row + the new key/value, layered over the env base, through the SAME coercion (``settings_overlay._coerce`` — one place for the string → field-value decision) and the REAL ``Settings`` validators. A bad shape OR a cross-field rule is a 422 that names the field — and nothing is written (the caller writes only on return).""" kwargs: dict[str, Any] = {} for key, raw in candidate.items(): try: kwargs[key] = _coerce(key, raw) except ValueError as exc: raise HTTPException( status_code=422, detail=f"invalid value for {key}: {exc}" ) from exc try: Settings(**kwargs) except ValidationError as exc: err = exc.errors()[0] field = str(err["loc"][0]) raise HTTPException( status_code=422, detail=f"invalid value for {field}: {err['msg']}" ) from exc #: A git URL with embedded credentials (``://:@``) — the #: phase-173 leak class. Refused for ``docs_repo`` on the way in: #: the credentials belong in ``docs_user`` / ``docs_token`` (C4). _EMBEDDED_CREDS = re.compile(r"://[^/@:]+:[^@/]+@") def _refuse_embedded_credentials(key: str, value: str) -> None: """The phase-173 guard (C4): a ``docs_repo`` PUT whose value embeds ``://:@`` is a 422 that names the replacement — the leak cannot be re-created from the UI. Only ``docs_repo`` is guarded: ``ssh://`` URLs, local paths and clean ``https://`` URLs pass untouched (a ``://user:pass@`` *subdomain* does not match — the trailing ``@`` requirement does that job). A legacy ``.env`` value with embedded credentials is NOT a PUT — the task-04 startup migration handles that path.""" if key == "docs_repo" and _EMBEDDED_CREDS.search(value): raise HTTPException( status_code=422, detail=( "docs_repo must not contain embedded credentials — " "keep the URL clean and set them in the Docs user / " "Docs token fields instead" ), ) @router.get("", response_model=AdminSettingsOut) def get_catalogue( db: Session = Depends(get_db), # noqa: B008 ) -> AdminSettingsOut: """The whole catalogue (78 items, ``GROUPS`` order — the stable order the E2E pins against). Reads the table once, resolves the effective ``Settings`` through ``get_settings()`` (the overlay — DB-over-env, task 02), and emits one item per field. GETs log nothing here: the phase-167 ``ResponseBudgetMiddleware`` already writes the ``api method=… bytes=…`` line, and the whole catalogue must stay under ``BOR_LOG_LARGE_RESPONSE_BYTES``' INFO bar (78 items ≈ 20 KB — verified in task 06 with the audit script). """ rows = _rows(db) effective = get_settings() items = [ _item(key, rows, effective) for group in GROUPS for key, meta in CATALOG.items() if meta.group == group ] return AdminSettingsOut( items=items, totals=AdminSettingsTotals( count=len(items), overridden=sum(1 for item in items if item.source == "db"), ), overlay_enabled=enabled(), ) @router.put("/{key}", response_model=AdminSettingItem) def set_value( key: str, payload: AdminSettingIn, db: Session = Depends(get_db), # noqa: B008 ) -> AdminSettingItem: """Set one value (store it in ``app_settings`` — env-native string form, D1), make it live (``invalidate_settings()`` — task 03) and return the refreshed item. The write path (LOCKED A5, A3, A4, C4): unknown key → 404; a non-overridable key → 422 naming the env var (A3: the engine is built from ``database_url`` — an override is a chicken-and-egg brick; ``chunks.embedding`` is fixed at ``embedding_dim`` by A6, so a dim override would corrupt retrieval); a ``docs_repo`` value with embedded credentials → 422 pointing at the ``docs_user`` / ``docs_token`` fields (C4 — the phase-173 leak class, refused on the way in); a blank value for a secret → 422 (a self-lockout; ``DELETE`` is the explicit clear); then the FULL candidate set is validated before anything is stored (a 422 names the field and writes nothing). """ meta = CATALOG.get(key) if meta is None: raise HTTPException(status_code=404, detail=f"Unknown setting {key}") if not meta.overridable: raise HTTPException( status_code=422, detail=f"{env_var(key)} is not settable from the database", ) _refuse_embedded_credentials(key, payload.value) if meta.secret and not payload.value.strip(): raise HTTPException( status_code=422, detail=( f"{env_var(key)} must not be blank — use Reset to fall " "back to the environment" ), ) # The candidate set: the overridable stored rows + this key/value # (the same drop rules ``settings_overlay.merge`` applies on the # read side — an orphan / non-overridable row can never affect # validation or the effective set). rows = _rows(db) candidate = { k: v for k, v in rows.items() if k in CATALOG and CATALOG[k].overridable } candidate[key] = payload.value _validate_candidate(candidate) # The write: upsert on the PK (INSERT … ON CONFLICT (key) DO # UPDATE — the row's updated_at moves with the save). stmt = pg_insert(AppSetting).values(key=key, value=payload.value) stmt = stmt.on_conflict_do_update( index_elements=["key"], set_={"value": stmt.excluded.value, "updated_at": func.now()}, ) db.execute(stmt) db.commit() # One line per write, never the value (AGENTS rule 10, A4). logger.info("settings action=set key=%s status=200", key) # Make it live (task 03): clear the cache + run the runtime reset # hooks, then report the refreshed item from the fresh settings. invalidate_settings() return _item(key, _rows(db), get_settings()) @router.delete("/{key}", response_model=AdminSettingItem) def reset_value( key: str, db: Session = Depends(get_db), # noqa: B008 ) -> AdminSettingItem: """Reset one key to env (LOCKED A1: the reset is a row DELETE — never an empty value, so the resolver never distinguishes "no override" from "override to empty"). Idempotent: deleting a key with NO ROW is a success, not a 404 (the panel's per-row Reset is safe to double-click — the env value is already in effect; an unknown *setting* key is a 404, like the PUT — a typo in the key is a client bug, and the item shape needs the catalog metadata, so "nothing to reset" can only mean "the row is already gone"). A successful delete logs one value-free line; a no-op double Reset logs nothing. """ if key not in CATALOG: raise HTTPException(status_code=404, detail=f"Unknown setting {key}") result = db.execute( delete(AppSetting).where(AppSetting.key == key).returning(AppSetting.key) ) db.commit() if len(result.all()): logger.info("settings action=clear key=%s", key) invalidate_settings() return _item(key, _rows(db), get_settings()) @router.delete("", response_model=AdminSettingsCleared) def reset_all(db: Session = Depends(get_db)) -> AdminSettingsCleared: # noqa: B008 """The panel's "Clear all overrides": delete every row (the env values win again for all 78 settings), invalidate (task 03) and report how many rows went.""" result = db.execute(delete(AppSetting).returning(AppSetting.key)) db.commit() count = len(result.all()) logger.info("settings action=clear_all count=%d", count) invalidate_settings() return AdminSettingsCleared(cleared=count)