Files
brain-of-reese/.agent/phases/complete/16_admin_auth/00_phase.md
T
ducoterra 4971e2859d
Build and Push Containers / build-and-push-app (push) Successful in 12s
Build and Push Containers / build-and-push-db (push) Successful in 10s
chore(agent): track .agent/ planning tree in git
Remove the blanket .agent/ gitignore so the phase roadmap, user
stories, reports, and PLAN.md are versioned with the code. Only
runtime artifacts (.agent/phase-sessions/, .agent/pipeline.log)
remain ignored. Update AGENTS.md git protocol rule to match.
2026-09-01 10:18:22 -04:00

11 KiB
Raw Blame History

Phase 16 — Admin Sign-In (Single-Admin Auth)

Story: .agent/user_stories/admin-auth.md Context: owner request 2026-08-22 — "add authentication. Only the admin user (there will be only one admin user, me) should be able to tune the outputs and view the entire sources page. Anonymous users should only be able to chat and view relevant documents from that chat." Owner-confirmed choices: plaintext BOR_ADMIN_PASSWORD; soft document rule (anon may open any document by direct URL — the catalog, not the viewer, is gated); UX: header Sign in / Sign out, Sources shows a soft gate state (not a redirect), tuning UI completely hidden from anonymous.

Objective

Single-admin password login backed by a signed session cookie (no new services, no new packages, no DB tables): the admin can tune (Phase 15 steering) and see the full Sources catalog; anonymous users keep chat + document viewer. Revises LOCKED A10 with owner permission (2026-08-22) — the public API stays stateless; the session cookie is the only session state.

Dependencies

  • All of 01–15 (complete). Specifically: 15_steering_notes (the gated tuning feature), 10_story_document_viewer (the anonymous document path), 02_story_import_documents (Sources page being gated), 08_story_dark_tech_theme (Phase-08 tokens for the login page), 12_header_consistency (the auth link joins .header-inner), 14_chat_persistence (restored messages must also omit Tune buttons for anonymous).

Design

  • Mechanism: Starlette SessionMiddleware (ships with FastAPI; itsdangerous is already a starlette dependency) → zero new packages, zero new services (A12 untouched). Cookie bor_session, same_site="lax", https_only=False (homelab HTTP — documented in README), max age 12 h sliding (BOR_SESSION_MAX_AGE, default 43200).
  • Config (fail-loud, A6 spirit): admin_password (BOR_ADMIN_PASSWORD, plaintext in gitignored .env) + session_secret (BOR_SESSION_SECRET, random hex; README one-liner python -c 'import secrets;print(secrets.token_hex(32))'). Either empty → create_app() raises RuntimeError naming the missing BOR_ variable(s) before the app serves anything.
  • Password check: secrets.compare_digest (constant-time); one admin → one generic 401 "invalid password" (no user enumeration).
  • API surface (app/api/auth.py, new router):
    • POST /api/login {password} → 204 + session {"admin": true}; 401 on mismatch (no session set).
    • POST /api/logout → 204; clears the session (idempotent for anon).
    • GET /api/whoami → {"authenticated": bool, "role": "admin"|"anonymous"} (drives all UI gating; trivially testable).
    • require_admin dependency in app/core/auth.py: reads request.session, else 403 {"detail": "admin only"}.
    • Gated: GET /api/docs + the whole /api/steering router (router-level dependencies=[Depends(require_admin)]).
    • Public (unchanged): /api/chat, /api/documents/content (soft rule — note it in the docstring), /api/suggestions, /api/health, all static pages.
  • UI (Phase-08 tokens, WCAG 2.1 AA, ≥44px targets, focus-visible, contrast ≥4.5:1):
    • /login.html + frontend/assets/login.js: standard app frame + sticky header (brand + nav, same as other pages — Phase 12 consistency), centered card: #login-form with visually-hidden <label> + #login-password (type=password, autocomplete="current-password"), submit "Sign in", #login-error role=alert. On submit → POST /api/login; 204 → location to ?next (same-origin /… only, default /sources.html); 401 → announce + keep form. On load: GET /api/whoami already-admin → redirect to next immediately.
    • Chat (index.html, app.js): .header-inner gains #sign-in-link (<a> → /login.html?next=/sources.html) and #sign-out-btn (<button>, aria-label "Sign out") — exactly one visible, decided by /api/whoami at load. Anonymous: #steering-toggle + #steering-panel hidden, no steering notes fetch, and no .tune-btn injected on new or Phase-14-restored messages. Sign out → POST /api/logout → location.reload().
    • Sources (sources.html, sources.js): new #sources-gate block (heading "Sign in to view the full catalog", copy, sign-in link → /login.html?next=/sources.html, ≥44px). sources.js fetches whoami before /api/docs: anonymous → show gate, hide stat cards + .sources-shell table, skip the docs fetch; admin → today's behavior.
    • Document viewer: untouched (anonymous OK).
  • Non-goals: no rate limiting / lockout, no HTTPS enforcement, no multi-user, no per-user history, no schema change / no migration.

Tasks

  1. app/config.py — add admin_password: str = "", session_secret: str = "", session_max_age: int = 43_200, session_cookie: str = "bor_session" (documented as auth settings).
  2. app/core/auth.py (new) — ensure_admin_configured(settings) -> None (RuntimeError naming missing BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET), check_password(candidate, expected) -> bool (secrets.compare_digest), require_admin(request) FastAPI dependency (403), sign_in(session) / sign_out(session) helpers.
  3. app/api/auth.py (new) — login / logout / whoami routes; LoginRequest schema in app/schemas.py; mount in app/main.py (with the other API routers, before the static catch-all).
  4. app/main.py::create_app — call ensure_admin_configured(settings) first; app.add_middleware(SessionMiddleware, secret_key=…, max_age=…, same_site="lax").
  5. app/api/docs.py, app/api/steering.py — require_admin on list_documents and on the steering router; get_document_content stays public (docstring: soft rule, Phase 16).
  6. frontend/login.html, frontend/assets/login.js, frontend/assets/styles.css — login page per Design (landmarks, skip-link, label, live error, Phase-08 tokens).
  7. frontend/index.html, frontend/assets/app.js, frontend/assets/styles.css — whoami on load → isAdmin; auth link in .header-inner; gate steering toggle/panel + tune-button injection (incl. the restore path); sign-out handler.
  8. frontend/sources.html, frontend/assets/sources.js, frontend/assets/styles.css — #sources-gate; whoami-before-docs fetch; hide stats + table for anonymous.
  9. Docs & config — .env.example: BOR_ADMIN_PASSWORD=, BOR_SESSION_SECRET=, # BOR_SESSION_MAX_AGE=43200; README "Admin & sign-in" section (setup one-liner, fail-loud behavior, anon vs admin capability table); PLAN revisions: A10 → revised 2026-08-22 (owner permission): single-admin signed-cookie auth — public: chat / documents / suggestions / health; admin-only: docs catalog + steering; §4 API table (+ /api/login, /api/logout, /api/whoami, auth column); §7.5 new ids (#sign-in-link, #sign-out-btn, #login-form, #login-password, #login-error, #sources-gate); §13 (auth hook now done — keep the multi-user/Valkey line); §12 roadmap row 16.
  10. Test fixtures — tests/conftest.py: os.environ.setdefault BOR_ADMIN_PASSWORD/BOR_SESSION_SECRET (known test values) before the app.main import (same pattern as BOR_RELEVANCE_THRESHOLD); tests/e2e/conftest.py: set both in app_server's env + export an ADMIN_PASSWORD constant for tests; tests/e2e/auth_helpers.py (new): login(page, app_url, password=None, next=None) performing the real form login (wrong-password variant for the error test).
  11. E2E regression adaptations — tests/e2e/test_steering.py (log in before any tuning), tests/e2e/test_import_documents.py + tests/e2e/test_document_back_navigation.py (log in for Sources-table assertions), tests/e2e/test_header_consistency.py (auth link presence/consistency; height assertions unchanged).

Locked decisions

  • A10 revised with owner permission (2026-08-22): single-admin auth via signed cookie; public API endpoints remain stateless. Recorded as a PLAN §2 revision (owner permission noted), not a silent deviation.
  • A12 untouched — no new services (SessionMiddleware/itsdangerous ship with starlette). A13 untouched — no migration needed. A16 untouched — one new story E2E suite + adapted regressions. A11 untouched — vanilla frontend, no CDN. No other anchor changed.

Testing & Quality

  • Unit (tests/unit/test_auth.py): ensure_admin_configured (missing password / missing secret / both set; plus create_app raising with app.main.settings monkeypatched invalid); check_password (match / mismatch / empty); require_admin (admin session passes, anonymous → 403); whoami payload shape; sign_in/sign_out session semantics.
  • Integration (tests/integration/test_auth_api.py): wrong password → 401 and subsequent /api/steering still 403; correct → 204 + cookie → whoami admin, /api/docs 200, steering GET/POST/DELETE 201/200/204; logout → 403 again; anonymous /api/documents/content still 200 (seeded doc) and /api/chat still streams (regression guard); /api/whoami anonymous shape.
  • Coverage: uv run pytest --cov=app --cov-report=term-missing >90% on app/.
  • E2E: tests/e2e/test_admin_auth.py — the six scenarios in the story's Playwright Mapping Rule.
  • Regression (each green in isolation): test_steering.py, test_import_documents.py, test_document_back_navigation.py, test_header_consistency.py, test_chat_rag.py, test_document_viewer.py.
  • Lint/types: uv run ruff check . && uv run pyright clean.

Completion Criteria

  • uv run uvicorn app.main:app with BOR_ADMIN_PASSWORD unset fails at startup naming the missing variable(s); with both auth vars set it serves.
  • Anonymous: POST /api/chat streams; GET /api/docs → 403; GET /api/documents/content?source=…&path=… → 200; /sources.html shows #sources-gate; chat UI has no Tune button / Tuning panel; header shows Sign in.
  • POST /api/login (correct) → 204 + cookie → /api/whoami {"authenticated": true, "role": "admin"} → Sources + tuning work → POST /api/logout → 403 again.
  • uv run pytest --cov=app --cov-report=term-missing > 90%; uv run ruff check . && uv run pyright clean.
  • uv run pytest tests/e2e/test_admin_auth.py -v --no-cov green in isolation; all regression suites above green in isolation.
  • UI Structure Check (AGENTS.md rule 5): login page — landmarks, labeled control, contrast ≥4.5:1, focus-visible, role=alert error, centered card in the standard frame, no CDN tags.
  • One --no-gpg-sign commit (below); .agent/phases/todo/16_admin_auth.md moved to .agent/phases/complete/.

Commit

git add -A .agent/ app/ frontend/ tests/ README.md .env.example && git commit --no-gpg-sign -m "feat(auth): single-admin password login (signed cookie) — gate tuning + Sources catalog, keep chat and document viewer public"