Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
155 lines
6.3 KiB
Python
155 lines
6.3 KiB
Python
"""Authentication (phase 16 single-admin; phase 79 token users).
|
|
|
|
One admin (the owner), one plaintext password; admin-issued access
|
|
tokens (``bor_`` + 32 hex — see :mod:`app.core.tokens`) for handed-out
|
|
users. The mechanism is Starlette's ``SessionMiddleware``
|
|
(itsdangerous-signed cookie — no server-side store, no new services):
|
|
the public API stays stateless, the cookie is the *only* session state.
|
|
|
|
Contract:
|
|
* ``ensure_admin_configured`` — fail-loud startup gate: the app must name
|
|
the missing ``BOR_`` variable(s) instead of serving anything.
|
|
* ``check_password`` — constant-time compare; exactly one generic 401
|
|
message (no user enumeration, there is no second user).
|
|
* ``require_admin`` — FastAPI dependency; anonymous callers get 403
|
|
``{"detail": "admin only"}`` (the admin-only surfaces: ``GET
|
|
/api/docs``, the whole ``/api/steering`` router, the token admin
|
|
API, …).
|
|
* ``require_user`` — FastAPI dependency (phase 79); admin OR a live
|
|
token session passes, everything else gets 401 ``authentication
|
|
required``. The token path live-checks the ``api_tokens`` row on
|
|
every request (the PK lookup IS the revocation check) and drops a
|
|
dead session (row revoked or gone) from the cookie right there.
|
|
* ``sign_in`` / ``sign_out`` — session-dict helpers for the API routes.
|
|
One session dict carries BOTH roles (an admin browser that also holds
|
|
a token reports admin); ``sign_out``'s ``session.clear()`` wipes
|
|
everything — one logout, both roles.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
import uuid
|
|
from collections.abc import MutableMapping
|
|
from typing import Any
|
|
|
|
from fastapi import Depends, HTTPException, Request
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import Settings
|
|
from app.db import get_db
|
|
from app.models import ApiToken
|
|
|
|
#: The session key the single admin is stored under.
|
|
ADMIN_SESSION_KEY = "admin"
|
|
#: The session key a token holder is stored under (phase 79).
|
|
USER_SESSION_KEY = "user"
|
|
#: The session key holding the ``api_tokens`` row id of a token session —
|
|
#: ``require_user``'s live check fetches that row on every request.
|
|
USER_TOKEN_ID_KEY = "user_token_id"
|
|
|
|
|
|
def ensure_admin_configured(settings: Settings) -> None:
|
|
"""Refuse to boot without admin auth (fail-loud, A6 spirit).
|
|
|
|
Raises :class:`RuntimeError` naming every missing ``BOR_`` variable so
|
|
the startup log tells the operator exactly what to set.
|
|
"""
|
|
missing = [
|
|
env_name
|
|
for env_name, value in (
|
|
("BOR_ADMIN_PASSWORD", settings.admin_password),
|
|
("BOR_SESSION_SECRET", settings.session_secret),
|
|
)
|
|
if not value.strip()
|
|
]
|
|
if missing:
|
|
raise RuntimeError(
|
|
"Brain of Reese cannot start: admin auth is not configured. "
|
|
f"Set the missing variable(s): {', '.join(missing)} "
|
|
"(see .env.example and the README 'Admin & sign-in' section)."
|
|
)
|
|
|
|
|
|
def check_password(candidate: str, expected: str) -> bool:
|
|
"""Constant-time password check (``secrets.compare_digest``).
|
|
|
|
One admin → one generic 401 on any mismatch: the response never reveals
|
|
whether the password was *close*, empty, or not (no user enumeration).
|
|
"""
|
|
return secrets.compare_digest(candidate.encode("utf-8"), expected.encode("utf-8"))
|
|
|
|
|
|
def require_admin(request: Request) -> None:
|
|
"""FastAPI dependency: allow the signed-in admin, else 403.
|
|
|
|
Reads the cookie-backed session installed by ``SessionMiddleware``;
|
|
a request without a valid admin session gets 403 ``admin only``.
|
|
"""
|
|
if not request.session.get(ADMIN_SESSION_KEY):
|
|
raise HTTPException(status_code=403, detail="admin only")
|
|
|
|
|
|
def require_user(request: Request, db: Session = Depends(get_db)) -> None: # noqa: B008
|
|
"""FastAPI dependency: allow the admin or a LIVE token holder, else 401.
|
|
|
|
Guards the app surface (``POST /api/chat``, ``GET /api/suggestions``,
|
|
``GET /api/documents/content`` — phase 79: the ONLY anonymous content
|
|
is the shared chats). The matrix:
|
|
|
|
* admin key set → pass, token state irrelevant (an admin browser
|
|
that also holds a token still passes as admin);
|
|
* ``user`` key set → the ``api_tokens`` row for ``user_token_id`` is
|
|
fetched (a PK hit — the live revocation check, no session store):
|
|
row missing OR ``revoked_at`` set → BOTH user keys are popped from
|
|
the session (the dead session is dropped NOW — the next
|
|
``whoami`` is anonymous) and the request gets 401;
|
|
* neither key → 401.
|
|
|
|
The failure detail is ``authentication required`` on 401 — not 403:
|
|
there is no higher privilege that would unblock an anonymous caller
|
|
(phase 79 auth-error semantics), unlike ``require_admin``'s surfaces.
|
|
"""
|
|
if request.session.get(ADMIN_SESSION_KEY):
|
|
return
|
|
if request.session.get(USER_SESSION_KEY):
|
|
token_id_raw = request.session.get(USER_TOKEN_ID_KEY)
|
|
token_id: uuid.UUID | None = None
|
|
if isinstance(token_id_raw, str):
|
|
try:
|
|
token_id = uuid.UUID(token_id_raw)
|
|
except ValueError:
|
|
token_id = None # corrupt session — treat as a missing row
|
|
row = (
|
|
db.execute(select(ApiToken).where(ApiToken.id == token_id))
|
|
.scalars()
|
|
.first()
|
|
if token_id is not None
|
|
else None
|
|
)
|
|
if row is None or row.revoked_at is not None:
|
|
request.session.pop(USER_SESSION_KEY, None)
|
|
request.session.pop(USER_TOKEN_ID_KEY, None)
|
|
raise HTTPException(status_code=401, detail="authentication required")
|
|
return
|
|
raise HTTPException(status_code=401, detail="authentication required")
|
|
|
|
|
|
def sign_in(session: MutableMapping[str, Any]) -> None:
|
|
"""Mark the (cookie-backed) session as the single admin.
|
|
|
|
Writing the key marks the session modified, so the middleware emits
|
|
the signed ``bor_session`` cookie with the configured Max-Age.
|
|
"""
|
|
session[ADMIN_SESSION_KEY] = True
|
|
|
|
|
|
def sign_out(session: MutableMapping[str, Any]) -> None:
|
|
"""Clear the session state (the route additionally expires the cookie).
|
|
|
|
An emptied dict is not re-persisted by the middleware, so the API
|
|
route pairs this with ``response.delete_cookie`` to make the browser
|
|
drop the signed cookie immediately.
|
|
"""
|
|
session.clear()
|