phase: 173_docs_push_creds_split
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:
+46
-12
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user