feat(auth): single-admin password login (signed cookie) — gate tuning + Sources catalog, keep chat and document viewer public
This commit is contained in:
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user