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`.
149 lines
5.9 KiB
Python
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
|
|
)
|