phase: 173_docs_push_creds_split
Build and Push Containers / build-and-push-app (push) Successful in 1m41s
Build and Push Containers / build-and-push-db (push) Successful in 11s

All gates green. Final report:

**Phase 173 — final verification pass (all tasks already complete in `complete/`)**

- Verified the full implementation is in the working tree: `docs_user`/`docs_token` settings + catalogue rows (task 01), `docs_repo` PUT 422 guard + `docs_token` write-only pin (task 02), `run_git(env)` + `push_document(user, token)` credential env (task 03), `settings_migrate.py` wired into `create_app()` (task 04), E2E suite (task 05).
- **Completion criteria:**
  1. GET `/api/admin/settings` lists `docs_user` (plain) + `docs_token` (secret, `value=null`), token never in any body — ✅ `test_docs_token_is_write_only_masked_end_to_end` + E2E sentinel-absent-from-DOM
  2. PUT `docs_repo` with `://user:pass@` → 422 naming the fields; clean URL passes — ✅ integration + E2E UI-side
  3. Token set → `GIT_CURL_USERNAME`/`GIT_CURL_PASSWORD`/`GIT_TERMINAL_PROMPT=0` on every git step; empty → `env=None` — ✅ mocked-git unit tests
  4. Legacy env split once at startup, idempotent, one INFO line, `.env` untouched — ✅ `test_settings_migrate.py` (4 shapes) + `test_docs_push_migrate.py`
  5. Full suite green, coverage >90%, lint clean — ✅
  6. E2E isolated: `test_docs_push_creds.py` 4/4; regression set (panel + layout + response_to_docs) 21/21 — ✅
  7. Commit + phase move — harness's job (per instructions, not mine).
  8. README/AGENTS consistent — ✅ (below)
- Tests/lint: `uv run pytest` exit 0 (4112 passed) · `uv run pytest --cov=app --cov-report=term-missing` TOTAL 99% · `uv run ruff check .` clean · `uv run pyright` 0 errors
- Docs: README.md (incl. Docs-push config section, guard/migration paragraph, `.env.example` block) and AGENTS.md verified consistent in full; fixed two stale docstrings in `app/config.py` + `app/core/settings_catalog.py` (`is_set` → the API's actual `has_value` field).
- No live-infrastructure changes (no live step in this phase). No next pending phase — `todo/` held only 173.
This commit is contained in:
2026-10-10 16:47:39 -04:00
parent f27106a638
commit a86caec821
56 changed files with 3177 additions and 39 deletions
+46 -12
View File
@@ -24,10 +24,13 @@ Routes (under ``/api`` via the ``main`` registration):
(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 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.
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"``.
@@ -51,6 +54,7 @@ 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
@@ -210,11 +214,37 @@ def _validate_candidate(candidate: dict[str, str]) -> None:
) from exc
#: A git URL with embedded credentials (``://<user>:<pass>@``) — 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
``://<user>:<pass>@`` 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 (75 items, ``GROUPS`` order — the stable
"""The whole catalogue (78 items, ``GROUPS`` order — the stable
order the E2E pins against).
Reads the table once, resolves the effective ``Settings`` through
@@ -222,7 +252,7 @@ def get_catalogue(
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 (75 items ≈ 20 KB
under ``BOR_LOG_LARGE_RESPONSE_BYTES``' INFO bar (78 items ≈ 20 KB
— verified in task 06 with the audit script).
"""
rows = _rows(db)
@@ -253,14 +283,17 @@ def set_value(
form, D1), make it live (``invalidate_settings()`` — task 03) and
return the refreshed item.
The write path (LOCKED A5, A3, A4): unknown key → 404; a
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 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).
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:
@@ -270,6 +303,7 @@ def set_value(
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,
@@ -337,7 +371,7 @@ def reset_value(
@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 75 settings), invalidate (task 03) and
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()
+5
View File
@@ -310,6 +310,11 @@ def push_doc_draft(
rel_path=path,
content=row.body,
commit_message=f"docs: {row.title}",
# Phase 173 (C2/C5): the docs-push credentials travel as git's
# GIT_CURL_* env (never in the argv); an empty token leaves the
# invocations byte-identical to the pre-phase anonymous push.
user=settings.docs_user,
token=settings.docs_token,
)
except DocsPushError as exc:
# Phase 84 (SEC-08): git's stderr may echo the remote URL with