feat(auth): single-admin password login (signed cookie) — gate tuning + Sources catalog, keep chat and document viewer public

This commit is contained in:
2026-08-23 19:58:39 -04:00
parent fc0d9a2d5c
commit cbc263a4b2
46 changed files with 1555 additions and 691 deletions
+63
View File
@@ -0,0 +1,63 @@
"""Auth API — single-admin sign-in (phase 16; A10 revised 2026-08-22).
* ``POST /api/login`` — 204 + signed session cookie on success; 401
``invalid password`` on any mismatch (constant-time, one generic
message, no session set).
* ``POST /api/logout`` — 204; clears the session and expires the cookie
(idempotent for anonymous callers).
* ``GET /api/whoami`` — ``{"authenticated": bool, "role":
"admin"|"anonymous"}``; the single source of truth for all UI gating.
The public API otherwise stays stateless (A10): chat, the document
content endpoint (soft rule — anonymous may open any document by direct
URL), suggestions, and health never require the cookie.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response
from app.config import get_settings
from app.core.auth import ADMIN_SESSION_KEY, check_password, sign_in, sign_out
from app.schemas import LoginRequest, WhoamiResponse
router = APIRouter(tags=["auth"])
@router.post("/login", status_code=204)
def login(payload: LoginRequest, request: Request) -> Response:
"""Sign in the single admin.
Success: 204 + the signed ``bor_session`` cookie (``same_site=lax``,
12 h default lifetime). Failure: one generic 401 — the admin count is
one, so there is nothing else to leak, and a wrong password must not
set any session state.
"""
settings = get_settings()
if check_password(payload.password, settings.admin_password):
sign_in(request.session)
return Response(status_code=204)
raise HTTPException(status_code=401, detail="invalid password")
@router.post("/logout", status_code=204)
def logout(request: Request, response: Response) -> Response:
"""Sign out: clear the session AND expire the browser cookie.
``sign_out`` empties the session dict (which the middleware does not
re-persist — an empty session has nothing to sign), so this route also
sends ``delete_cookie`` to make the browser drop the signed cookie
right now. Idempotent: an anonymous logout is still a 204.
"""
sign_out(request.session)
response.delete_cookie(get_settings().session_cookie, path="/")
return Response(status_code=204)
@router.get("/whoami", response_model=WhoamiResponse)
def whoami(request: Request) -> WhoamiResponse:
"""Who is the caller? Drives every UI gating decision (phase 16)."""
authenticated = bool(request.session.get(ADMIN_SESSION_KEY))
return WhoamiResponse(
authenticated=authenticated,
role="admin" if authenticated else "anonymous",
)
+15 -3
View File
@@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.auth import require_admin
from app.db import get_db
from app.models import Chunk, Document
from app.schemas import DocContent, DocList, DocSummary
@@ -28,11 +29,17 @@ def doc_format(path: str) -> str:
@router.get("/docs", response_model=DocList)
def list_documents(db: Session = Depends(get_db)) -> DocList: # noqa: B008
def list_documents(
db: Session = Depends(get_db), # noqa: B008
_admin: None = Depends(require_admin), # noqa: B008
) -> DocList:
"""All indexed documents with per-document chunk counts.
An empty list means the knowledge base has not been imported yet —
the Sources page renders its designed empty state in that case.
Admin-only (phase 16 — the catalog is what the sign-in gates; the
document viewer itself stays public, see below). Anonymous callers
get 403 ``admin only`` and the Sources page renders its sign-in gate
instead. An empty list means the knowledge base has not been imported
yet — the Sources page renders its designed empty state in that case.
"""
rows = db.execute(
select(
@@ -71,6 +78,11 @@ def get_document_content(
Stateless (A10) and database-only: unknown pairs — including traversal
strings such as ``../../etc/passwd`` — are just non-existent rows and
map to 404 ``{detail: "document not found"}``.
Deliberately PUBLIC for anonymous callers (phase 16 soft rule, owner
decision 2026-08-22): the *catalog* (``GET /api/docs``) is what the
sign-in gates, not the viewer — chat cites documents and anyone may
open a cited document by direct URL.
"""
row = db.execute(
select(Document, func.count(Chunk.id).label("chunks"))
+14 -6
View File
@@ -1,11 +1,14 @@
"""Steering notes API — tune how Brain answers (phase 15, story
``steering-notes``).
Stateless CRUD under ``/api/steering`` (A10): notes are owner instructions
stored in Postgres (``steering_notes``) and read into the system prompt of
**every** chat turn as the ``<tuning>`` section (see
:func:`app.rag.prompts.build_steering_section` and
:func:`app.api.chat.chat`).
Admin-only CRUD under ``/api/steering`` (phase 16, A10 revised): notes
are owner instructions stored in Postgres (``steering_notes``) and read
into the system prompt of **every** chat turn as the ``<tuning>`` section
(see :func:`app.rag.prompts.build_steering_section` and
:func:`app.api.chat.chat`). The whole router sits behind
:func:`app.core.auth.require_admin` — anonymous callers get 403 on every
steering route (the chat turn itself reads the table in-process and
stays public).
"""
from __future__ import annotations
@@ -15,12 +18,17 @@ from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.auth import require_admin
from app.db import get_db
from app.models import SteeringNote
from app.schemas import SteeringNote as SteeringNoteOut
from app.schemas import SteeringNoteIn, SteeringNoteList
router = APIRouter(prefix="/steering", tags=["steering"])
router = APIRouter(
prefix="/steering",
tags=["steering"],
dependencies=[Depends(require_admin)], # phase 16: tuning is admin-only
)
def load_steering_notes(db: Session) -> list[str]:
+14
View File
@@ -79,6 +79,20 @@ class Settings(BaseSettings):
hybrid_lexical_candidates: int = 30
rrf_k: int = 60
# --- Admin & sign-in (phase 16; A10 revised 2026-08-22) ---
# Single-admin auth via a signed session cookie (Starlette
# SessionMiddleware — no new services, no DB tables). Both secrets are
# REQUIRED at startup: ``create_app()`` refuses to boot when either is
# empty (``app.core.auth.ensure_admin_configured``). The password is
# plaintext on purpose (homelab scope, owner decision 2026-08-22);
# the session secret signs the cookie (``secrets.token_hex(32)``).
admin_password: str = ""
session_secret: str = ""
#: Signed-cookie lifetime in seconds (default 12 h, refreshed on
#: session writes — sliding for an active admin).
session_max_age: int = 43_200
session_cookie: str = "bor_session"
# --- Import scope (A9, revised 2026-08-21) ---
# Comma-separated list of lowercased file extensions (no dot) imported
# by ``scripts/import_docs.py``. Hidden (dot) path components are always
+89
View File
@@ -0,0 +1,89 @@
"""Single-admin authentication (phase 16; LOCKED A10 revised 2026-08-22).
One admin (the owner), one plaintext password, one signed cookie. The
mechanism is Starlette's ``SessionMiddleware`` (itsdangerous-signed cookie
— no server-side store, no new services, no DB tables): 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"}`` (used by ``GET /api/docs`` and the whole
``/api/steering`` router).
* ``sign_in`` / ``sign_out`` — session-dict helpers for the API routes.
"""
from __future__ import annotations
import secrets
from collections.abc import MutableMapping
from typing import Any
from fastapi import HTTPException, Request
from app.config import Settings
#: The session key the single admin is stored under.
ADMIN_SESSION_KEY = "admin"
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 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()
+26
View File
@@ -4,6 +4,11 @@ Boots logging + conditional debugpy, then creates the FastAPI app:
API routes first (so they win over the catch-all), and the static frontend
mounted last. No CDN: everything the browser needs is served by this
process from local files (see PLAN §UI/UX — No External Dependencies).
Phase 16 (A10 revised): before anything is served, admin auth must be
configured (fail-loud), and the app wraps every route in Starlette's
SessionMiddleware — a signed ``bor_session`` cookie is the only session
state in the system.
"""
from __future__ import annotations
@@ -12,13 +17,16 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app.api.auth import router as auth_router
from app.api.chat import router as chat_router
from app.api.docs import router as docs_router
from app.api.health import router as health_router
from app.api.steering import router as steering_router
from app.api.suggestions import router as suggestions_router
from app.config import get_settings
from app.core.auth import ensure_admin_configured
from app.core.debugging import configure_debugging
from app.core.logging import configure_logging
@@ -30,10 +38,28 @@ logger = logging.getLogger("app")
def create_app() -> FastAPI:
# Fail loud BEFORE serving anything (phase 16): missing
# BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET raises at boot, naming the
# variable(s) — the app never starts in a half-authenticated state.
ensure_admin_configured(settings)
app = FastAPI(title=settings.app_name, version=settings.app_version)
# Signed single-admin session cookie (Starlette middleware, itsdangerous
# signer — no server-side store, no new services). Homelab HTTP: same_site
# is "lax" and https_only stays off (documented in the README).
app.add_middleware(
SessionMiddleware,
secret_key=settings.session_secret,
session_cookie=settings.session_cookie,
max_age=settings.session_max_age,
same_site="lax",
https_only=False,
)
# API routes first so they take precedence over the catch-all static mount.
app.include_router(health_router, prefix="/api")
app.include_router(auth_router, prefix="/api")
app.include_router(suggestions_router, prefix="/api")
app.include_router(docs_router, prefix="/api")
app.include_router(chat_router, prefix="/api")
+17
View File
@@ -22,6 +22,23 @@ class ChatRequest(BaseModel):
message: str = Field(min_length=1, max_length=4000)
class LoginRequest(BaseModel):
"""``POST /api/login`` body (phase 16): the single admin's password.
An empty or wrong password is a 401 with one generic detail — never a
422 that would hint at input-shape differences.
"""
password: str = ""
class WhoamiResponse(BaseModel):
"""``GET /api/whoami`` (phase 16) — drives all UI gating."""
authenticated: bool
role: str # "admin" | "anonymous"
class SourceRef(BaseModel):
source: str
path: str