"""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``). """ 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, ) 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)