Files
ducoterra 4dbac1660a
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 101_tokens_page_overhaul
All gates verified green — no defects found, nothing to fix. Final report:

## Phase 101 — final verification pass (all tasks already complete; verified, not re-implemented)

**Verified against the working tree** (task files 01–04 present in `complete/`):
- Active/revoked table split (Status column gone, section hidden while 0 revoked, per-table search with query state surviving re-renders) in `frontend/index.html` + `tokens.js`
- `generateToken()` refuses blank/whitespace names (`Give the token a name first.`, no request); `|| "token"` fallback deleted
- Atomic rotation: `regenerate_token` service + `POST /api/tokens/{id}/regenerate` (201 new-plaintext-once / 404 / 409 / router-wide 403) + `TokenListItem.revoked_at` (D5)
- Regenerate two-step confirm UI + CSS (`.token-regenerate`, neutral hover, no new hue); A4 pins intact

**Test / lint / coverage results:**
- `uv run pytest` → **2065 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- `uv run pytest tests/e2e/test_tokens_page.py -v --no-cov` → **4 passed** (isolation, DB up)
- Regression, each in isolation: `test_api_tokens.py` **9 passed**, `test_admin_auth.py` **6 passed**, `test_shared_header.py` **6 passed**, `test_theme_semantic_completion.py` **8 passed** (its revoked-pill pin was correctly re-scoped to the revoked table in this phase)

**Completion criteria:** 1 ✓ split+search (E2E 1–2) · 2 ✓ required name (E2E 3 + source pin) · 3 ✓ rotation end-to-end, old token refused at gate (E2E 4 + API 404/409 pinned) · 4 ✓ A4 holds (list carries no plaintext/hashes) · 5 ✓ suite/coverage/lint green · 6 ✓ E2E + regressions green in isolation · 7 commit left to the harness per executor rules (all changes uncommitted in the working tree)

**Deviations:** none. Next pending phase: `98_sync_summary_visibility`.
2026-09-12 15:16:02 -04:00

161 lines
6.6 KiB
Python

"""API-token service (phase 79, task 02).
Generates, stores, looks up and revokes the admin-issued access tokens
(``api_tokens`` rows — phase 79, task 01). The trust model (owner-locked
A4): the plaintext token (``bor_`` + 32 hex chars) exists only in the 201
response of the create call, returned **exactly once**; the row carries
the SHA-256 hex digest of the **full** token string and nothing else.
Lookup security — by hash, not by compare:
:func:`find_active_by_token` hashes the presented token and performs one
``token_hash ==`` lookup — a unique-index hit (``ix_api_tokens_token_hash``).
SHA-256's pre-image resistance means there is **no token-enumeration or
timing surface beyond the DB lookup itself**: an attacker holding the table
cannot turn a stored hash back into a working token, and a wrong candidate
simply misses the index. This is the deliberate contrast with
:func:`app.core.auth.check_password`'s constant-time compare — there IS
nothing to compare in constant time here, only to *look up*; replicating a
compare would be theatre, so the contrast is documented, not replicated.
House commit convention (the ``app.rag.sources_meta`` pattern): the service
functions flush but never commit — the calling endpoint owns the commit, so
a failed request can never leave a half-applied token mutation. This holds
for the composite :func:`regenerate_token` rotation too: the revocation
stamp and the successor's insert are flushed by ONE call and covered by
ONE commit — a create failure rolls the revoke back with it.
Rotation (phase 101): :func:`regenerate_token` revokes the old row and
creates its successor (same label) atomically. A4 never weakens — the old
plaintext was already one-shot (its 201 body was the only wire moment),
and the new plaintext is returned by the service exactly once, for the
rotation's 201 body to ship.
"""
from __future__ import annotations
import hashlib
import secrets
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import ApiToken
#: The plaintext token's shape: fixed prefix + 32 hex chars
#: (``secrets.token_hex(16)`` — 128 bits of entropy).
TOKEN_PREFIX = "bor_"
def generate_token() -> str:
"""One fresh plaintext token: ``bor_`` + 32 hex chars (128-bit).
The prefix is a human/parse marker only — the HASH covers the full
string, so the prefix is never the secret.
"""
return TOKEN_PREFIX + secrets.token_hex(16)
def hash_token(token: str) -> str:
"""The stored credential: the SHA-256 hex digest of the **full** token.
Hashing the full string (not the suffix) means a stripped prefix can
never collide with another token's hash. Deterministic — the same
token always yields the same 64-hex digest (the unique-index key).
"""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_token(db: Session, label: str) -> tuple[ApiToken, str]:
"""Create one named token; return the row + the plaintext ONCE.
``label`` is stripped before storing; non-emptiness is the API
layer's job (the 422 boundary — the service trusts it, A4). The row
only ever carries the hash (``token_hash``); the returned ``str`` is
the one and only moment the plaintext exists outside this function
(the endpoint ships it in the 201 body).
Flushes, does not commit — the caller commits (so the row + its
server-default ``created_at`` are durable only when the endpoint's
response can be built).
"""
plaintext = generate_token()
row = ApiToken(label=label.strip(), token_hash=hash_token(plaintext))
db.add(row)
db.flush()
return row, plaintext
def find_active_by_token(db: Session, token: str) -> ApiToken | None:
"""Resolve a presented plaintext token to its ACTIVE row, or ``None``.
Hash → ``token_hash ==`` lookup → ``revoked_at IS NULL``. ANY other
shape is a miss — there is no "almost" path: the hash of a malformed
string (wrong prefix, truncated, empty, …) simply matches no row,
so every failure mode returns the same ``None`` (the caller maps
that to one generic 401 — no enumeration, A4's auth-error contract).
"""
row = (
db.execute(select(ApiToken).where(ApiToken.token_hash == hash_token(token)))
.scalars()
.first()
)
if row is None or row.revoked_at is not None:
return None
return row
def mark_used(tok: ApiToken) -> None:
"""Bump ``last_used_at`` to now (UTC) — the caller commits."""
tok.last_used_at = datetime.now(UTC)
def revoke(db: Session, token_id: uuid.UUID) -> bool:
"""Stamp ``revoked_at`` (UTC now); return False when the row is missing.
Idempotent: an already-revoked row keeps its ORIGINAL stamp (the
revocation time is the first one, never re-stamped on a second
call) and the call still returns True — the row exists and is dead.
Returns False only for a missing id (the endpoint maps that to
404 ``token not found``). Flushes, does not commit.
"""
row = db.get(ApiToken, token_id)
if row is None:
return False
if row.revoked_at is None:
row.revoked_at = datetime.now(UTC)
return True
class TokenAlreadyRevoked(Exception):
"""A revoked (dead) token cannot be rotated — the regenerate endpoint's 409."""
def regenerate_token(
db: Session, token_id: uuid.UUID
) -> tuple[ApiToken, str] | None:
"""Rotate one ACTIVE token; return the (NEW row, new plaintext) ONCE.
The rotation is ONE atomic unit: ``revoked_at`` is stamped on the old
row (the :func:`revoke` primitive — its first stamp IS the
revocation time) and the successor row is created with the SAME
label (the hand-out name persists, phase 101 D2) — both writes are
flushed here and covered by the caller's SINGLE commit, so a create
failure rolls the revoke back with it (the service flushes, never
commits — the house convention). A missing id returns ``None`` (the
endpoint's 404); an already-revoked row raises
:class:`TokenAlreadyRevoked` (a dead token cannot be rotated — the
endpoint's 409). A4 never weakens: the old plaintext was already
one-shot, and the new plaintext exists outside this function only in
this return value — the rotation's 201 body is its one wire moment.
"""
row = db.get(ApiToken, token_id)
if row is None:
return None
if row.revoked_at is not None:
raise TokenAlreadyRevoked()
revoke(db, row.id) # stamps the first (and only) revocation time
new_row, plaintext = create_token(db, row.label)
db.flush() # one unit for the caller's commit: stamp + successor
return new_row, plaintext