Files
praxis/server/operator/credentials.py
T
Praxis CI ec6fcc64bc feat(milestone): merge phase/02 cohort-dashboard → milestone/v0.4-operator-tier
Phase 2 complete — Cohort Dashboard + Aggregation:
- Cohort aggregation pipeline (k-anon ≥10 write-time suppression, async hook, nightly 03:00 CT reconcile)
- 4 auth-gated operator API endpoints (cohort, mastery, failure-patterns, credentials)
- React cohort dashboard (BrowserRouter, login, 3 views, inline SVG sparklines, auth gate)
- SPA fallback via SpaStaticFiles subclass (G-041 — NOT catch-all route)
- G-038 differencing-attack test (unit + API e2e)
- 317 pytest pass, 36 skip, 0 fail; 17/17 vitest pass; npm build + typecheck clean

---ci---
project: praxis
phase: 2
milestone: v0.4
status: complete
requirements:
  covered: [REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02]
  partial: []
---/ci---
2026-08-04 11:43:34 +00:00

78 lines
2.3 KiB
Python

"""GET/POST /api/operator/credentials — VC management (TASK-08-04, D-057).
Auth-gated. GET lists issued VCs from Postgres issued_credentials (operator's
issuance log). POST /{id}/revoke revokes a VC (status='revoked',
revoked_at=now()). Revoked credentials fail verification. No PII beyond what
the credential asserts (D-043).
"""
from __future__ import annotations
import datetime as _dt
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from server.auth.dependencies import current_operator
from server.auth.models import Operator
from server.operator._common import require_pg_store
router = APIRouter(prefix="/api/operator", tags=["operator-credentials"])
class CredentialOut(BaseModel):
id: str
learner_ref: str
vc_type: str | None = None
status: str
issued_at: _dt.datetime | None = None
revoked_at: _dt.datetime | None = None
class CredentialListResponse(BaseModel):
credentials: list[CredentialOut]
class OkResponse(BaseModel):
ok: bool = True
id: str
status: str
@router.get("/credentials", response_model=CredentialListResponse)
async def list_credentials(
request: Request,
op: Operator = Depends(current_operator),
) -> CredentialListResponse:
pg_store = await require_pg_store(request)
rows = await pg_store.list_credentials()
creds = [
CredentialOut(
id=str(r["id"]),
learner_ref=r["learner_ref"],
vc_type=r.get("vc_type"),
status=r.get("status", "active"),
issued_at=r.get("issued_at"),
revoked_at=r.get("revoked_at"),
)
for r in rows
]
return CredentialListResponse(credentials=creds)
@router.post("/credentials/{cred_id}/revoke", response_model=OkResponse)
async def revoke_credential(
cred_id: str,
request: Request,
op: Operator = Depends(current_operator),
) -> OkResponse:
pg_store = await require_pg_store(request)
row = await pg_store.get_credential(cred_id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="credential not found")
await pg_store.set_credential_status(cred_id, "revoked")
return OkResponse(ok=True, id=cred_id, status="revoked")
__all__ = ["router"]