347 lines
13 KiB
Python
347 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
import threading
|
|
from typing import Any, Callable
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from .auth.keycloak import AuthContext
|
|
from .utils.logging import task_context
|
|
|
|
|
|
def _register_admin_routes(app: FastAPI, require_auth: Callable, deps: Callable[[], Any]) -> None: # noqa: PLR0915
|
|
@app.get("/api/admin/access/requests")
|
|
def list_access_requests(ctx: AuthContext = Depends(require_auth)) -> JSONResponse:
|
|
"""Return pending access requests for authenticated administrators."""
|
|
|
|
module = deps()
|
|
module._require_admin(ctx)
|
|
module.logger.info(
|
|
"list access requests",
|
|
extra={"event": "access_requests_list", "actor": ctx.username or ""},
|
|
)
|
|
try:
|
|
rows = module.storage.list_pending_requests()
|
|
except Exception:
|
|
raise HTTPException(status_code=502, detail="failed to load requests")
|
|
|
|
output: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
created_at = row.get("created_at")
|
|
output.append(
|
|
{
|
|
"id": row.get("request_code"),
|
|
"username": row.get("username"),
|
|
"email": row.get("contact_email") or "",
|
|
"first_name": row.get("first_name") or "",
|
|
"last_name": row.get("last_name") or "",
|
|
"request_code": row.get("request_code"),
|
|
"created_at": created_at.isoformat() if isinstance(created_at, datetime) else "",
|
|
"note": row.get("note") or "",
|
|
}
|
|
)
|
|
return JSONResponse({"requests": output})
|
|
|
|
@app.get("/api/admin/access/flags")
|
|
def list_access_flags(ctx: AuthContext = Depends(require_auth)) -> JSONResponse:
|
|
"""Return Keycloak groups that can be applied as access-request flags."""
|
|
|
|
module = deps()
|
|
module._require_admin(ctx)
|
|
flags = module.settings.allowed_flag_groups
|
|
if module.keycloak_admin.ready():
|
|
try:
|
|
flags = module.keycloak_admin.list_group_names(exclude={"admin"})
|
|
except Exception:
|
|
flags = module.settings.allowed_flag_groups
|
|
return JSONResponse({"flags": flags})
|
|
|
|
@app.get("/api/admin/audit/events")
|
|
def list_audit_events(
|
|
limit: int = 200,
|
|
event_type: str | None = None,
|
|
ctx: AuthContext = Depends(require_auth),
|
|
) -> JSONResponse:
|
|
"""Return recent audit events with optional type filtering."""
|
|
|
|
module = deps()
|
|
module._require_admin(ctx)
|
|
try:
|
|
rows = module.storage.list_events(limit=limit, event_type=event_type)
|
|
except Exception:
|
|
raise HTTPException(status_code=502, detail="failed to load audit events")
|
|
|
|
output: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
created_at = row.get("created_at")
|
|
output.append(
|
|
{
|
|
"id": row.get("id"),
|
|
"event_type": row.get("event_type"),
|
|
"detail": module._parse_event_detail(row.get("detail")),
|
|
"created_at": created_at.isoformat() if isinstance(created_at, datetime) else "",
|
|
}
|
|
)
|
|
return JSONResponse({"events": output})
|
|
|
|
@app.get("/api/admin/audit/task-runs")
|
|
def list_audit_task_runs(
|
|
limit: int = 200,
|
|
request_code: str | None = None,
|
|
task: str | None = None,
|
|
ctx: AuthContext = Depends(require_auth),
|
|
) -> JSONResponse:
|
|
"""Return recorded background task runs for admin audit views."""
|
|
|
|
module = deps()
|
|
module._require_admin(ctx)
|
|
try:
|
|
rows = module.storage.list_task_runs(limit=limit, request_code=request_code, task=task)
|
|
except Exception:
|
|
raise HTTPException(status_code=502, detail="failed to load task runs")
|
|
|
|
output: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
started_at = row.get("started_at")
|
|
finished_at = row.get("finished_at")
|
|
output.append(
|
|
{
|
|
"id": row.get("id"),
|
|
"request_code": row.get("request_code") or "",
|
|
"task": row.get("task") or "",
|
|
"status": row.get("status") or "",
|
|
"detail": module._parse_event_detail(row.get("detail")),
|
|
"started_at": started_at.isoformat() if isinstance(started_at, datetime) else "",
|
|
"finished_at": finished_at.isoformat() if isinstance(finished_at, datetime) else "",
|
|
"duration_ms": row.get("duration_ms"),
|
|
}
|
|
)
|
|
return JSONResponse({"task_runs": output})
|
|
|
|
@app.get("/api/admin/cluster/state")
|
|
def get_cluster_state(ctx: AuthContext = Depends(require_auth)) -> JSONResponse:
|
|
"""Return the latest cluster-state snapshot to authenticated administrators."""
|
|
|
|
module = deps()
|
|
module._require_admin(ctx)
|
|
snapshot = module.storage.latest_cluster_state()
|
|
if not snapshot:
|
|
raise HTTPException(status_code=404, detail="cluster state unavailable")
|
|
return JSONResponse(snapshot)
|
|
|
|
@app.get("/api/internal/cluster/state")
|
|
def get_cluster_state_internal() -> JSONResponse:
|
|
"""Return the latest cluster-state snapshot for trusted internal callers."""
|
|
|
|
module = deps()
|
|
snapshot = module.storage.latest_cluster_state()
|
|
if not snapshot:
|
|
raise HTTPException(status_code=404, detail="cluster state unavailable")
|
|
return JSONResponse(snapshot)
|
|
|
|
@app.post("/api/admin/access/requests/{username}/approve")
|
|
async def approve_access_request(
|
|
username: str,
|
|
request: Request,
|
|
ctx: AuthContext = Depends(require_auth),
|
|
) -> JSONResponse:
|
|
"""Approve a verified access request and start account provisioning."""
|
|
|
|
module = deps()
|
|
module._require_admin(ctx)
|
|
with task_context("admin.access.approve"):
|
|
payload = await module._read_json_payload(request)
|
|
allowed_flags = module._allowed_flag_groups()
|
|
flags = [flag for flag in module._flags_from_payload(payload) if flag in allowed_flags]
|
|
note = module._note_from_payload(payload)
|
|
|
|
decided_by = ctx.username or ""
|
|
try:
|
|
row = module.portal_db.fetchone(
|
|
"""
|
|
UPDATE access_requests
|
|
SET status = 'approved',
|
|
decided_at = NOW(),
|
|
decided_by = %s,
|
|
approval_flags = %s,
|
|
approval_note = %s
|
|
WHERE username = %s
|
|
AND status = 'pending'
|
|
AND email_verified_at IS NOT NULL
|
|
RETURNING request_code
|
|
""",
|
|
(decided_by or None, flags or None, note, username),
|
|
)
|
|
except Exception:
|
|
raise HTTPException(status_code=502, detail="failed to approve request")
|
|
|
|
if not row:
|
|
module.logger.info(
|
|
"access request approval ignored",
|
|
extra={"event": "access_request_approve", "actor": decided_by, "username": username, "status": "skipped"},
|
|
)
|
|
module._record_event(
|
|
"access_request_approve",
|
|
{
|
|
"actor": decided_by,
|
|
"username": username,
|
|
"status": "skipped",
|
|
},
|
|
)
|
|
return JSONResponse({"ok": True, "request_code": ""})
|
|
|
|
request_code = row.get("request_code") or ""
|
|
if request_code:
|
|
threading.Thread(
|
|
target=module.provisioning.provision_access_request,
|
|
args=(request_code,),
|
|
daemon=True,
|
|
).start()
|
|
module.logger.info(
|
|
"access request approved",
|
|
extra={
|
|
"event": "access_request_approve",
|
|
"actor": decided_by,
|
|
"username": username,
|
|
"request_code": request_code,
|
|
},
|
|
)
|
|
module._record_event(
|
|
"access_request_approve",
|
|
{
|
|
"actor": decided_by,
|
|
"username": username,
|
|
"request_code": request_code,
|
|
"status": "ok",
|
|
"flags": flags,
|
|
"note": note or "",
|
|
},
|
|
)
|
|
return JSONResponse({"ok": True, "request_code": request_code})
|
|
|
|
@app.post("/api/admin/access/requests/{username}/deny")
|
|
async def deny_access_request(
|
|
username: str,
|
|
request: Request,
|
|
ctx: AuthContext = Depends(require_auth),
|
|
) -> JSONResponse:
|
|
"""Deny a pending access request and record the administrator decision."""
|
|
|
|
module = deps()
|
|
module._require_admin(ctx)
|
|
with task_context("admin.access.deny"):
|
|
payload = await module._read_json_payload(request)
|
|
note = module._note_from_payload(payload)
|
|
decided_by = ctx.username or ""
|
|
|
|
try:
|
|
row = module.portal_db.fetchone(
|
|
"""
|
|
UPDATE access_requests
|
|
SET status = 'denied',
|
|
decided_at = NOW(),
|
|
decided_by = %s,
|
|
denial_note = %s
|
|
WHERE username = %s AND status = 'pending'
|
|
RETURNING request_code
|
|
""",
|
|
(decided_by or None, note, username),
|
|
)
|
|
except Exception:
|
|
raise HTTPException(status_code=502, detail="failed to deny request")
|
|
|
|
if not row:
|
|
module.logger.info(
|
|
"access request denial ignored",
|
|
extra={"event": "access_request_deny", "actor": decided_by, "username": username, "status": "skipped"},
|
|
)
|
|
module._record_event(
|
|
"access_request_deny",
|
|
{
|
|
"actor": decided_by,
|
|
"username": username,
|
|
"status": "skipped",
|
|
},
|
|
)
|
|
return JSONResponse({"ok": True, "request_code": ""})
|
|
module.logger.info(
|
|
"access request denied",
|
|
extra={
|
|
"event": "access_request_deny",
|
|
"actor": decided_by,
|
|
"username": username,
|
|
"request_code": row.get("request_code") or "",
|
|
},
|
|
)
|
|
module._record_event(
|
|
"access_request_deny",
|
|
{
|
|
"actor": decided_by,
|
|
"username": username,
|
|
"request_code": row.get("request_code") or "",
|
|
"status": "ok",
|
|
"note": note or "",
|
|
},
|
|
)
|
|
return JSONResponse({"ok": True, "request_code": row.get("request_code")})
|
|
|
|
@app.post("/api/access/requests/{request_code}/retry")
|
|
def retry_access_request(request_code: str) -> JSONResponse:
|
|
"""Reset failed provisioning tasks so an approved request can retry."""
|
|
|
|
module = deps()
|
|
code = (request_code or "").strip()
|
|
if not code:
|
|
raise HTTPException(status_code=400, detail="request_code is required")
|
|
if not module.keycloak_admin.ready():
|
|
raise HTTPException(status_code=503, detail="server not configured")
|
|
|
|
try:
|
|
row = module.portal_db.fetchone(
|
|
"SELECT status FROM access_requests WHERE request_code = %s",
|
|
(code,),
|
|
)
|
|
except Exception:
|
|
raise HTTPException(status_code=502, detail="failed to load request")
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="not found")
|
|
|
|
status = (row.get("status") or "").strip()
|
|
if status not in {"accounts_building", "approved"}:
|
|
raise HTTPException(status_code=409, detail="request not retryable")
|
|
|
|
try:
|
|
module.portal_db.execute(
|
|
"UPDATE access_requests SET provision_attempted_at = NULL WHERE request_code = %s",
|
|
(code,),
|
|
)
|
|
module.portal_db.execute(
|
|
"""
|
|
UPDATE access_request_tasks
|
|
SET status = 'pending',
|
|
detail = 'retry requested',
|
|
updated_at = NOW()
|
|
WHERE request_code = %s AND status = 'error'
|
|
""",
|
|
(code,),
|
|
)
|
|
except Exception:
|
|
raise HTTPException(status_code=502, detail="failed to update retry state")
|
|
|
|
threading.Thread(
|
|
target=module.provisioning.provision_access_request,
|
|
args=(code,),
|
|
daemon=True,
|
|
).start()
|
|
module._record_event(
|
|
"access_request_retry",
|
|
{
|
|
"request_code": code,
|
|
"status": "ok",
|
|
},
|
|
)
|
|
return JSONResponse({"ok": True, "request_code": code})
|