""" AZA / MedWork - Admin Routes (FastAPI) Separiert die Admin-Endpunkte aus license_server.py: - /admin/set_plan - /admin/revoke_token - /admin/set_status - /admin/audit/list Alle Endpunkte sind per AZA_ADMIN_KEY geschützt. """ from __future__ import annotations from typing import Optional, Callable, Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field # ----------------------------- # Models # ----------------------------- class AdminSetPlanRequest(BaseModel): admin_key: str = Field(..., min_length=1, max_length=512) email: str = Field(..., min_length=3, max_length=255) plan: str = Field(..., min_length=1, max_length=64) class AdminSetPlanResponse(BaseModel): ok: bool email: str plan: str message: str class AdminRevokeTokenRequest(BaseModel): admin_key: str = Field(..., min_length=1, max_length=512) token: str = Field(..., min_length=1, max_length=4096) class AdminRevokeTokenResponse(BaseModel): ok: bool token: str message: str class AdminSetStatusRequest(BaseModel): admin_key: str = Field(..., min_length=1, max_length=512) email: str = Field(..., min_length=3, max_length=255) status: str = Field(..., min_length=1, max_length=64) class AdminSetStatusResponse(BaseModel): ok: bool email: str status: str message: str class AdminAuditListRequest(BaseModel): admin_key: str = Field(..., min_length=1, max_length=512) limit: int = Field(50, ge=1, le=500) class AdminAuditItem(BaseModel): id: int action: str email: Optional[str] = None token: Optional[str] = None old_value: Optional[str] = None new_value: Optional[str] = None created_at: str class AdminAuditListResponse(BaseModel): ok: bool items: list[AdminAuditItem] # ----------------------------- # Router factory # ----------------------------- def build_admin_router( *, get_db: Callable[[], Any], admin_key: str, log_admin_action: Callable[..., None], ) -> APIRouter: """ get_db: function returning sqlite3.Connection (row_factory set) admin_key: AZA_ADMIN_KEY (string) log_admin_action: function(conn, action, email?, token?, old_value?, new_value?) """ router = APIRouter(prefix="/admin", tags=["admin"]) def _require_admin_key(provided: str) -> None: if not admin_key: raise HTTPException(status_code=503, detail="Admin not configured (AZA_ADMIN_KEY missing)") if (provided or "").strip() != admin_key: raise HTTPException(status_code=401, detail="Invalid admin key") @router.post("/set_plan", response_model=AdminSetPlanResponse) def admin_set_plan(req: AdminSetPlanRequest): _require_admin_key(req.admin_key) email = req.email.strip().lower() plan = req.plan.strip().lower() if not email or "@" not in email: raise HTTPException(status_code=400, detail="Invalid email") if not plan: raise HTTPException(status_code=400, detail="Invalid plan") conn = get_db() try: row = conn.execute("SELECT id, plan FROM users WHERE email = ?", (email,)).fetchone() if not row: raise HTTPException(status_code=404, detail="User not found") old_plan = str(row["plan"]) if row["plan"] else "" conn.execute("UPDATE users SET plan = ? WHERE email = ?", (plan, email)) log_admin_action(conn, action="set_plan", email=email, old_value=old_plan, new_value=plan) conn.commit() return AdminSetPlanResponse(ok=True, email=email, plan=plan, message="Plan updated") finally: conn.close() @router.post("/revoke_token", response_model=AdminRevokeTokenResponse) def admin_revoke_token(req: AdminRevokeTokenRequest): _require_admin_key(req.admin_key) token = req.token.strip() if not token: raise HTTPException(status_code=400, detail="Invalid token") conn = get_db() try: row = conn.execute("SELECT token FROM tokens WHERE token = ?", (token,)).fetchone() if not row: raise HTTPException(status_code=404, detail="Token not found") conn.execute("UPDATE tokens SET revoked = 1 WHERE token = ?", (token,)) log_admin_action(conn, action="revoke_token", token=token, old_value="active", new_value="revoked") conn.commit() return AdminRevokeTokenResponse(ok=True, token=token, message="Token revoked") finally: conn.close() @router.post("/set_status", response_model=AdminSetStatusResponse) def admin_set_status(req: AdminSetStatusRequest): _require_admin_key(req.admin_key) email = req.email.strip().lower() status = req.status.strip().lower() if not email or "@" not in email: raise HTTPException(status_code=400, detail="Invalid email") if status not in ("active", "suspended", "cancelled"): raise HTTPException(status_code=400, detail="Invalid status (use active|suspended|cancelled)") conn = get_db() try: row = conn.execute("SELECT id, status FROM users WHERE email = ?", (email,)).fetchone() if not row: raise HTTPException(status_code=404, detail="User not found") old_status = str(row["status"]) if row["status"] else "" conn.execute("UPDATE users SET status = ? WHERE email = ?", (status, email)) log_admin_action(conn, action="set_status", email=email, old_value=old_status, new_value=status) conn.commit() return AdminSetStatusResponse(ok=True, email=email, status=status, message="Status updated") finally: conn.close() @router.post("/audit/list", response_model=AdminAuditListResponse) def admin_audit_list(req: AdminAuditListRequest): _require_admin_key(req.admin_key) limit = int(req.limit) conn = get_db() try: rows = conn.execute( """ SELECT id, action, email, token, old_value, new_value, created_at FROM admin_audit ORDER BY id DESC LIMIT ? """, (limit,), ).fetchall() items = [ AdminAuditItem( id=int(r["id"]), action=str(r["action"]), email=r["email"], token=r["token"], old_value=r["old_value"], new_value=r["new_value"], created_at=str(r["created_at"]), ) for r in rows ] return AdminAuditListResponse(ok=True, items=items) finally: conn.close() return router