Skip to content

Commit ee466be

Browse files
committed
feat(security): Phase 7c Step 8 — backend CRUD API for /api/admin/api-keys
5 endpoints all requiring admin scope: - GET /api/admin/api-keys: list all keys (metadata only, no hashes) - POST /api/admin/api-keys: create key, returns plaintext ONCE in response - GET /api/admin/api-keys/{id}: single key metadata - PATCH /api/admin/api-keys/{id}: update enabled/rate_limit/notes/printer_acl - DELETE /api/admin/api-keys/{id}: revoke key + invalidate bcrypt cache + clear rate-limiter bucket 8 unit tests cover full CRUD lifecycle: list empty, list existing, create (plaintext returned + hash not stored), get detail, patch fields, delete. Refs #22
1 parent 0104978 commit ee466be

3 files changed

Lines changed: 439 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
"""REST CRUD endpoints for API key management — Phase 7c Step 8.
2+
3+
All endpoints require ``admin`` scope.
4+
5+
Routes
6+
------
7+
GET /api/admin/api-keys — list all keys (metadata only, no hashes/plaintexts)
8+
POST /api/admin/api-keys — create key, returns plaintext ONCE in response
9+
GET /api/admin/api-keys/{id} — single key metadata
10+
PATCH /api/admin/api-keys/{id} — update enabled/rate_limit/notes
11+
DELETE /api/admin/api-keys/{id} — revoke key (sets enabled=False)
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from typing import Annotated
17+
from uuid import UUID
18+
19+
from fastapi import APIRouter, Depends, HTTPException, status
20+
from pydantic import BaseModel
21+
from sqlalchemy.ext.asyncio import AsyncSession
22+
23+
from app.auth.dependencies import AuthContext
24+
from app.auth.key_generator import generate_api_key
25+
from app.auth.scope_deps import require_admin
26+
from app.auth.verifier import invalidate_cache
27+
from app.db.session import get_session
28+
from app.models.api_key import ApiKey
29+
from app.repositories import api_keys as api_keys_repo
30+
from app.services.rate_limiter import _rate_limiter
31+
32+
router = APIRouter(prefix="/api/admin/api-keys", tags=["admin"])
33+
34+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
35+
AdminAuthDep = Annotated[AuthContext, Depends(require_admin)]
36+
37+
38+
# ---------------------------------------------------------------------------
39+
# Request / Response schemas
40+
# ---------------------------------------------------------------------------
41+
42+
43+
class ApiKeyCreate(BaseModel):
44+
name: str
45+
scopes: list[str]
46+
allowed_printer_ids: list[str] = []
47+
rate_limit_per_minute: int = 60
48+
notes: str | None = None
49+
expires_at: str | None = None # ISO-8601 string or null
50+
51+
52+
class ApiKeyCreateResponse(BaseModel):
53+
"""Returned ONCE on creation — includes plaintext. Never return again."""
54+
key_id: UUID
55+
plaintext: str
56+
prefix: str
57+
name: str
58+
scopes: list[str]
59+
60+
61+
class ApiKeyRead(BaseModel):
62+
"""Metadata-only view — no key_hash, no plaintext."""
63+
id: UUID
64+
name: str
65+
key_prefix: str
66+
scopes: list[str]
67+
allowed_printer_ids: list[str]
68+
rate_limit_per_minute: int
69+
enabled: bool
70+
created_at: str
71+
last_used_at: str | None
72+
last_used_ip: str | None
73+
expires_at: str | None
74+
notes: str | None
75+
76+
77+
class ApiKeyPatch(BaseModel):
78+
enabled: bool | None = None
79+
rate_limit_per_minute: int | None = None
80+
notes: str | None = None
81+
allowed_printer_ids: list[str] | None = None
82+
83+
84+
def _key_to_read(key: ApiKey) -> ApiKeyRead:
85+
return ApiKeyRead(
86+
id=key.id,
87+
name=key.name,
88+
key_prefix=key.key_prefix,
89+
scopes=key.scopes,
90+
allowed_printer_ids=key.allowed_printer_ids,
91+
rate_limit_per_minute=key.rate_limit_per_minute,
92+
enabled=key.enabled,
93+
created_at=key.created_at.isoformat() if key.created_at else "",
94+
last_used_at=key.last_used_at.isoformat() if key.last_used_at else None,
95+
last_used_ip=key.last_used_ip,
96+
expires_at=key.expires_at.isoformat() if key.expires_at else None,
97+
notes=key.notes,
98+
)
99+
100+
101+
# ---------------------------------------------------------------------------
102+
# Routes
103+
# ---------------------------------------------------------------------------
104+
105+
106+
@router.get(
107+
"",
108+
response_model=list[ApiKeyRead],
109+
summary="List all API keys",
110+
description="Returns metadata for all API keys. key_hash and plaintext are never included.",
111+
)
112+
async def list_api_keys(session: SessionDep, _auth: AdminAuthDep) -> list[ApiKeyRead]:
113+
from sqlalchemy import select
114+
from sqlmodel import SQLModel
115+
result = await session.execute(
116+
__import__("sqlalchemy", fromlist=["select"]).select(ApiKey)
117+
)
118+
keys = list(result.scalars())
119+
return [_key_to_read(k) for k in keys]
120+
121+
122+
@router.post(
123+
"",
124+
response_model=ApiKeyCreateResponse,
125+
status_code=status.HTTP_201_CREATED,
126+
summary="Create a new API key",
127+
description=(
128+
"Creates a new API key. The ``plaintext`` field in the response is the "
129+
"full key — it is shown ONCE and never stored. Copy it before closing "
130+
"this response. Subsequent GETs return only the prefix."
131+
),
132+
)
133+
async def create_api_key(
134+
body: ApiKeyCreate,
135+
session: SessionDep,
136+
_auth: AdminAuthDep,
137+
) -> ApiKeyCreateResponse:
138+
plaintext, prefix, hashed = generate_api_key()
139+
key = ApiKey(
140+
name=body.name,
141+
key_hash=hashed,
142+
key_prefix=prefix,
143+
scopes=body.scopes,
144+
allowed_printer_ids=body.allowed_printer_ids,
145+
rate_limit_per_minute=body.rate_limit_per_minute,
146+
notes=body.notes,
147+
enabled=True,
148+
)
149+
if body.expires_at:
150+
from datetime import datetime
151+
key.expires_at = datetime.fromisoformat(body.expires_at)
152+
153+
created = await api_keys_repo.create(session, key)
154+
return ApiKeyCreateResponse(
155+
key_id=created.id,
156+
plaintext=plaintext,
157+
prefix=prefix,
158+
name=created.name,
159+
scopes=created.scopes,
160+
)
161+
162+
163+
@router.get(
164+
"/{key_id}",
165+
response_model=ApiKeyRead,
166+
summary="Get API key metadata",
167+
description="Returns metadata for a single API key. key_hash and plaintext are never included.",
168+
)
169+
async def get_api_key(
170+
key_id: UUID,
171+
session: SessionDep,
172+
_auth: AdminAuthDep,
173+
) -> ApiKeyRead:
174+
key = await api_keys_repo.get(session, key_id)
175+
if key is None:
176+
raise HTTPException(
177+
status_code=status.HTTP_404_NOT_FOUND,
178+
detail=f"API key {key_id} not found",
179+
)
180+
return _key_to_read(key)
181+
182+
183+
@router.patch(
184+
"/{key_id}",
185+
response_model=ApiKeyRead,
186+
summary="Update API key metadata",
187+
description=(
188+
"Update ``enabled``, ``rate_limit_per_minute``, ``notes``, or "
189+
"``allowed_printer_ids``. Cannot change scopes or the key value itself — "
190+
"revoke and recreate for that."
191+
),
192+
)
193+
async def update_api_key(
194+
key_id: UUID,
195+
body: ApiKeyPatch,
196+
session: SessionDep,
197+
_auth: AdminAuthDep,
198+
) -> ApiKeyRead:
199+
key = await api_keys_repo.get(session, key_id)
200+
if key is None:
201+
raise HTTPException(
202+
status_code=status.HTTP_404_NOT_FOUND,
203+
detail=f"API key {key_id} not found",
204+
)
205+
if body.enabled is not None:
206+
key.enabled = body.enabled
207+
if body.rate_limit_per_minute is not None:
208+
key.rate_limit_per_minute = body.rate_limit_per_minute
209+
if body.notes is not None:
210+
key.notes = body.notes
211+
if body.allowed_printer_ids is not None:
212+
key.allowed_printer_ids = body.allowed_printer_ids
213+
214+
session.add(key)
215+
await session.commit()
216+
await session.refresh(key)
217+
return _key_to_read(key)
218+
219+
220+
@router.delete(
221+
"/{key_id}",
222+
status_code=status.HTTP_204_NO_CONTENT,
223+
summary="Revoke an API key",
224+
description=(
225+
"Sets ``enabled = False``. The key will be rejected on next use. "
226+
"The row is kept for audit purposes (jobs referencing this key_id "
227+
"remain intact)."
228+
),
229+
)
230+
async def revoke_api_key(
231+
key_id: UUID,
232+
session: SessionDep,
233+
_auth: AdminAuthDep,
234+
) -> None:
235+
key = await api_keys_repo.revoke(session, key_id)
236+
if key is None:
237+
raise HTTPException(
238+
status_code=status.HTTP_404_NOT_FOUND,
239+
detail=f"API key {key_id} not found",
240+
)
241+
# Invalidate bcrypt cache so the key is rejected immediately
242+
invalidate_cache(key.key_hash)
243+
# Clear rate-limiter bucket
244+
_rate_limiter.reset(key_id)

backend/app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
from app.api.routes import lookup as lookup_routes
8181
from app.api.routes import printers as printers_routes
8282
from app.api.routes import qr as qr_routes
83+
from app.api.routes.admin_api_keys import router as admin_api_keys_router
8384
from app.api.routes import templates as templates_routes
8485
from app.api.routes import webhooks as webhooks_routes
8586
from app.api.routes.print import router as print_router
@@ -596,6 +597,7 @@ async def readiness(
596597
app.include_router(lookup_routes.router)
597598
app.include_router(webhooks_routes.router)
598599
app.include_router(qr_routes.router)
600+
app.include_router(admin_api_keys_router)
599601

600602
_static_dir = Path(__file__).parent / "static"
601603
if _static_dir.exists():

0 commit comments

Comments
 (0)