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

149 lines
5.9 KiB
Python

"""Tokens admin API (phase 79, task 02).
The admin surface for the issued access tokens (owner-locked A4):
generate a named token (the plaintext is shown **exactly once**, in the
201 body), list tokens (display fields only — no plaintext, no hashes),
and revoke one (idempotent). The whole router sits behind
:func:`app.core.auth.require_admin` (router-wide ``dependencies`` — the
:mod:`app.api.doc_drafts` pattern): tokens are admin-only, so anonymous
callers get 403 on every route — and once the token-auth login lands
(task 03), a token *user* stays 403 here too (only the admin manages
tokens).
Routes (all under ``/api`` via the ``main`` registration):
``POST /api/tokens`` (201 ``TokenCreated`` — the ONE response shape
that carries the plaintext ``token``), ``GET /api/tokens``
(``TokenList`` — newest first, secret-free), ``POST
/api/tokens/{token_id}/revoke`` (204, idempotent; unknown id → 404
``token not found``), and ``POST /api/tokens/{token_id}/regenerate``
(201 ``TokenCreated`` — the atomic rotation: the old row is revoked
and its successor, same label, is created in ONE transaction; already
revoked → 409, unknown id → 404).
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core import tokens as token_service
from app.core.auth import require_admin
from app.db import get_db
from app.models import ApiToken
from app.schemas import TokenCreated, TokenCreateRequest, TokenList, TokenListItem
router = APIRouter(
prefix="/tokens",
tags=["tokens"],
dependencies=[Depends(require_admin)], # phase 79: the token admin surface is admin-only
)
@router.post("", response_model=TokenCreated, status_code=201)
def create_token(
payload: TokenCreateRequest,
db: Session = Depends(get_db), # noqa: B008
) -> TokenCreated:
"""Generate one named token (201).
The ``token`` field of this response is the ONE AND ONLY moment the
plaintext exists on the wire (A4): the row stores the SHA-256 hash
of the full token string, and no other response shape — in
particular the list — ever carries it. ``label`` is the hand-out
name, display-only and NOT unique (two tokens may share a label).
Blank/over-long labels are a 422 from the schema (the house
``ValueError`` pattern — fail loud at the boundary).
"""
row, plaintext = token_service.create_token(db, payload.label)
db.commit() # the service flushes; the endpoint owns the commit
db.refresh(row) # pulls the server-default created_at
return TokenCreated(
id=row.id, label=row.label, token=plaintext, created_at=row.created_at
)
@router.get("", response_model=TokenList)
def list_tokens(
db: Session = Depends(get_db), # noqa: B008
) -> TokenList:
"""All tokens, newest first (``created_at desc, id desc`` tiebreak).
Secret-free by construction: :class:`~app.schemas.TokenListItem` has
no ``token`` and no ``token_hash`` field — the list never carries a
credential in either form. ``revoked`` is derived from
``revoked_at is not None``; ``last_used_at`` stays null until the
token is first used (task 03 stamps it on ``POST /api/token-auth``)
"""
rows = (
db.execute(
select(ApiToken).order_by(ApiToken.created_at.desc(), ApiToken.id.desc())
)
.scalars()
.all()
)
items = [
TokenListItem(
id=row.id,
label=row.label,
created_at=row.created_at,
last_used_at=row.last_used_at,
revoked=row.revoked_at is not None,
revoked_at=row.revoked_at, # phase 101 D5: null while active
)
for row in rows
]
return TokenList(tokens=items)
@router.post("/{token_id}/revoke", status_code=204)
def revoke_token(
token_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> Response:
"""Revoke one token (204) — idempotent.
Already-revoked → still 204 with NO re-stamp (the original
``revoked_at`` — the revocation time — is preserved; the service
only stamps when unset). Unknown id → 404 ``token not found`` (one
message for every unknown id). Revocation takes effect immediately:
the holder's next request is refused (the task-03 live check).
"""
if not token_service.revoke(db, token_id):
raise HTTPException(status_code=404, detail="token not found")
db.commit()
return Response(status_code=204)
@router.post("/{token_id}/regenerate", response_model=TokenCreated, status_code=201)
def regenerate_token(
token_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> TokenCreated:
"""Rotate one active token (201) — the rotation is ONE transaction.
The old row is stamped ``revoked_at`` and the successor (SAME label,
fresh hash) is created in the same commit, so the holder's old token
is dead the instant the new one is live. This 201 is the NEW token's
ONE plaintext moment (A4 — the same contract as the create endpoint;
the old plaintext was already one-shot and is never re-shown).
A dead token cannot be rotated: an already-revoked id is a 409
``token already revoked``, and an unknown id a 404 ``token not
found`` (the revoke endpoint's exact message).
"""
try:
result = token_service.regenerate_token(db, token_id)
except token_service.TokenAlreadyRevoked:
raise HTTPException(
status_code=409, detail="token already revoked"
) from None
if result is None:
raise HTTPException(status_code=404, detail="token not found")
new_row, plaintext = result
db.commit() # ONE commit: the stamp + the create are atomic
db.refresh(new_row) # pulls the server-default created_at
return TokenCreated(
id=new_row.id, label=new_row.label, token=plaintext, created_at=new_row.created_at
)