"""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 )