"""API tokens for scripts and CI. Managing tokens needs a signed-in session, never another API token: a leaked CI credential should be able to do the job it was issued for, not mint itself a second one that survives the first being revoked. """ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel import Session, select from auth import require_admin_session from database import get_session from models.api_token import ( SCOPES, ApiToken, ApiTokenCreate, ApiTokenCreated, ApiTokenRead, ) from models.user import User from services import api_token_service, audit_service router = APIRouter(prefix="/api/auth/tokens", tags=["auth"]) def _ip(request: Request) -> str: return request.client.host if request.client else "unknown" def _to_read(row: ApiToken, session: Session) -> ApiTokenRead: owner = session.get(User, row.user_id) return ApiTokenRead( id=row.id, name=row.name, prefix=row.prefix, scope=row.scope, username=owner.username if owner else "(deleted)", expires_at=row.expires_at, last_used_at=row.last_used_at, created_at=row.created_at, expired=api_token_service.is_expired(row), ) @router.get("", response_model=list[ApiTokenRead]) def list_tokens( session: Session = Depends(get_session), _user: User = Depends(require_admin_session), ) -> list[ApiTokenRead]: rows = session.exec(select(ApiToken).order_by(ApiToken.created_at.desc())).all() return [_to_read(r, session) for r in rows] @router.post("", response_model=ApiTokenCreated, status_code=201) def create_token( body: ApiTokenCreate, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin_session), ) -> ApiTokenCreated: name = (body.name or "").strip() if not name: raise HTTPException(status_code=400, detail="A name is required") if body.scope not in SCOPES: raise HTTPException( status_code=400, detail=f"Scope must be one of {', '.join(SCOPES)}" ) if body.expires_in_days is not None and body.expires_in_days < 1: raise HTTPException(status_code=400, detail="Expiry must be at least a day") row, token = api_token_service.mint( session, name=name, user=user, scope=body.scope, expires_in_days=body.expires_in_days, ) audit_service.record( session, user=user.username, action="token.create", target=row.prefix, detail=f"{name} ({row.scope})", ip=_ip(request), ) # The only time the token itself is ever returned. return ApiTokenCreated(**_to_read(row, session).model_dump(), token=token) @router.delete("/{token_id}") def revoke_token( token_id: int, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin_session), ) -> dict: row = session.get(ApiToken, token_id) if not row: raise HTTPException(status_code=404, detail=f"Token {token_id} not found") prefix = row.prefix session.delete(row) session.commit() audit_service.record( session, user=user.username, action="token.revoke", target=prefix, detail=row.name, ip=_ip(request), ) return {"ok": True}