Files
gh-christianlouis-dmarq/backend/app/api/api_v1/endpoints/api_tokens.py
T
2026-05-23 19:38:27 +02:00

138 lines
4.1 KiB
Python

"""Admin API token management endpoints."""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.api_token import APIToken
from app.services.api_tokens import (
PUBLIC_READ_SCOPES,
create_api_token,
revoke_api_token,
token_to_dict,
)
from app.services.workspace_audit import record_workspace_audit_log
from app.services.workspaces import assign_default_workspace_to_unscoped_rows
router = APIRouter()
class APITokenCreateRequest(BaseModel):
"""Request body for creating a scoped API token."""
name: str = Field(..., min_length=1, max_length=120)
scopes: List[str] = Field(default_factory=lambda: sorted(PUBLIC_READ_SCOPES))
class APITokenResponse(BaseModel):
"""API-safe token metadata."""
id: int
name: str
key_prefix: str
scopes: List[str]
active: bool
created_at: str
last_used_at: Optional[str] = None
last_used_ip: Optional[str] = None
usage_count: int
revoked_at: Optional[str] = None
class APITokenCreateResponse(BaseModel):
"""New token response. The secret is returned once."""
token: str
metadata: APITokenResponse
class APITokenListResponse(BaseModel):
"""List of API token metadata rows."""
tokens: List[APITokenResponse]
available_scopes: List[str]
@router.get("", response_model=APITokenListResponse)
async def list_api_tokens(
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
):
"""List API token metadata without exposing raw secrets or hashes."""
rows = db.query(APIToken).order_by(APIToken.created_at.desc(), APIToken.id.desc()).all()
return APITokenListResponse(
tokens=[APITokenResponse(**token_to_dict(row)) for row in rows],
available_scopes=sorted(PUBLIC_READ_SCOPES),
)
@router.post("", response_model=APITokenCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_public_api_token(
payload: APITokenCreateRequest,
request: Request,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
):
"""Create a scoped API token for read-only automation."""
workspace = assign_default_workspace_to_unscoped_rows(db)
try:
created = create_api_token(db, name=payload.name, scopes=payload.scopes)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
record_workspace_audit_log(
db,
workspace=workspace,
action="api_token.created",
entity_type="api_token",
entity_id=created.token.id,
entity_name=created.token.name,
details={
"scopes": sorted(created.token.scopes.split(",")),
"key_prefix": created.token.key_prefix,
},
auth_context=_auth,
request=request,
commit=True,
)
return APITokenCreateResponse(
token=created.secret,
metadata=APITokenResponse(**token_to_dict(created.token)),
)
@router.delete("/{token_id}", status_code=status.HTTP_200_OK)
async def revoke_public_api_token(
token_id: int,
request: Request,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
):
"""Revoke a scoped API token."""
workspace = assign_default_workspace_to_unscoped_rows(db)
token = db.query(APIToken).filter(APIToken.id == token_id).first()
if not revoke_api_token(db, token_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API token not found",
)
record_workspace_audit_log(
db,
workspace=workspace,
action="api_token.revoked",
entity_type="api_token",
entity_id=token_id,
entity_name=token.name if token else None,
details={"key_prefix": token.key_prefix if token else None},
auth_context=_auth,
request=request,
commit=True,
)
return {"revoked": True}