diff --git a/ariadne/app.py b/ariadne/app.py index 58b6be3..f18ccf3 100644 --- a/ariadne/app.py +++ b/ariadne/app.py @@ -1,71 +1,49 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import datetime, timezone import json -import threading -from typing import Any, Callable +import sys +from typing import Any from fastapi import Body, Depends, FastAPI, HTTPException, Request from fastapi.responses import JSONResponse, Response from prometheus_client import CONTENT_TYPE_LATEST, generate_latest +from .app_account_routes import _register_account_routes +from .app_admin_routes import _register_admin_routes from .auth.keycloak import AuthContext, authenticator from .db.database import Database, DatabaseConfig -from .db.storage import Storage, TaskRunRecord +from .db.storage import Storage from .manager.provisioning import ProvisioningManager from .metrics.metrics import record_task_run from .scheduler.cron import CronScheduler from .services.cluster_state import run_cluster_state from .services.comms import comms from .services.firefly import firefly +from .services.image_sweeper import image_sweeper +from .services.jenkins_build_weather import collect_jenkins_build_weather +from .services.jenkins_workspace_cleanup import cleanup_jenkins_workspace_storage from .services.keycloak_admin import keycloak_admin from .services.keycloak_profile import run_profile_sync from .services.mailu import mailu from .services.mailu_events import mailu_events -from .services.nextcloud import nextcloud -from .services.image_sweeper import image_sweeper -from .services.jenkins_build_weather import collect_jenkins_build_weather -from .services.jenkins_workspace_cleanup import cleanup_jenkins_workspace_storage from .services.metis import metis from .services.metis_token_sync import metis_token_sync +from .services.nextcloud import nextcloud from .services.opensearch_prune import prune_indices from .services.platform_quality_probe import platform_quality_probe from .services.pod_cleaner import clean_finished_pods -from .services.vaultwarden_sync import run_vaultwarden_sync from .services.vault import vault +from .services.vaultwarden_sync import run_vaultwarden_sync from .services.wger import wger from .settings import settings -from .utils.errors import safe_error_detail from .utils.http import extract_bearer_token -from .utils.logging import LogConfig, configure_logging, get_logger, task_context +from .utils.logging import LogConfig, configure_logging, get_logger from .utils.passwords import random_password configure_logging(LogConfig(level=settings.log_level)) logger = get_logger(__name__) - -@dataclass(frozen=True) -class AccountTaskContext: - task_name: str - username: str - started: datetime - extra: dict[str, Any] | None = None - - -@dataclass(frozen=True) -class PasswordResetRequest: - task_name: str - service_label: str - username: str - mailu_email: str - password: str - sync_fn: Callable[[], dict[str, Any]] - password_attr: str - updated_attr: str - error_hint: str - portal_db = Database( settings.portal_database_url, DatabaseConfig( @@ -93,6 +71,7 @@ ariadne_db = Database( storage = Storage(ariadne_db, portal_db) provisioning = ProvisioningManager(portal_db, storage) scheduler = CronScheduler(storage, settings.schedule_tick_sec) +app = FastAPI(title=settings.app_name) def _record_event(event_type: str, detail: dict[str, Any] | str | None) -> None: @@ -111,9 +90,6 @@ def _parse_event_detail(detail: str | None) -> Any: return detail -app = FastAPI(title=settings.app_name) - - def _require_auth(request: Request) -> AuthContext: token = extract_bearer_token(request) if not token: @@ -169,92 +145,8 @@ def _allowed_flag_groups() -> list[str]: return settings.allowed_flag_groups -def _resolve_mailu_email(username: str) -> str: - mailu_email = f"{username}@{settings.mailu_domain}" - try: - user = keycloak_admin.find_user(username) or {} - attrs = user.get("attributes") if isinstance(user, dict) else None - if isinstance(attrs, dict): - raw_mailu = attrs.get("mailu_email") - if isinstance(raw_mailu, list) and raw_mailu: - return str(raw_mailu[0]) - if isinstance(raw_mailu, str) and raw_mailu: - return raw_mailu - except Exception: - return mailu_email - return mailu_email - - -def _record_account_task(ctx: AccountTaskContext, status: str, error_detail: str) -> None: - finished = datetime.now(timezone.utc) - duration_sec = (finished - ctx.started).total_seconds() - record_task_run(ctx.task_name, status, duration_sec) - try: - storage.record_task_run( - TaskRunRecord( - request_code=None, - task=ctx.task_name, - status=status, - detail=error_detail or None, - started_at=ctx.started, - finished_at=finished, - duration_ms=int(duration_sec * 1000), - ) - ) - except Exception: - pass - detail = {"username": ctx.username, "status": status, "error": error_detail} - if ctx.extra: - detail.update(ctx.extra) - _record_event(ctx.task_name, detail) - - -def _run_password_reset(request: PasswordResetRequest) -> JSONResponse: - started = datetime.now(timezone.utc) - task_ctx = AccountTaskContext( - task_name=request.task_name, - username=request.username, - started=started, - extra={"mailu_email": request.mailu_email}, - ) - status = "ok" - error_detail = "" - logger.info( - f"{request.service_label} password reset requested", - extra={"event": request.task_name, "username": request.username}, - ) - try: - result = request.sync_fn() - status_val = result.get("status") if isinstance(result, dict) else "error" - if status_val != "ok": - raise RuntimeError(f"{request.service_label} sync {status_val}") - - keycloak_admin.set_user_attribute( - request.username, - request.password_attr, - request.password, - ) - keycloak_admin.set_user_attribute( - request.username, - request.updated_attr, - datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - ) - - logger.info( - f"{request.service_label} password reset completed", - extra={"event": request.task_name, "username": request.username}, - ) - return JSONResponse({"status": "ok", "password": request.password}) - except HTTPException as exc: - status = "error" - error_detail = str(exc.detail) - raise - except Exception as exc: - status = "error" - error_detail = safe_error_detail(exc, request.error_hint) - raise HTTPException(status_code=502, detail=error_detail) - finally: - _record_account_task(task_ctx, status, error_detail) +def _app_module() -> Any: + return sys.modules[__name__] @app.on_event("startup") @@ -262,118 +154,34 @@ def _startup() -> None: provisioning.start() scheduler.add_task("schedule.mailu_sync", settings.mailu_sync_cron, lambda: mailu.sync("ariadne_schedule")) - scheduler.add_task( - "schedule.nextcloud_sync", - settings.nextcloud_sync_cron, - lambda: nextcloud.sync_mail(wait=False), - ) - scheduler.add_task( - "schedule.nextcloud_cron", - settings.nextcloud_cron, - lambda: nextcloud.run_cron(), - ) - scheduler.add_task( - "schedule.nextcloud_maintenance", - settings.nextcloud_maintenance_cron, - lambda: nextcloud.run_maintenance(), - ) + scheduler.add_task("schedule.nextcloud_sync", settings.nextcloud_sync_cron, lambda: nextcloud.sync_mail(wait=False)) + scheduler.add_task("schedule.nextcloud_cron", settings.nextcloud_cron, lambda: nextcloud.run_cron()) + scheduler.add_task("schedule.nextcloud_maintenance", settings.nextcloud_maintenance_cron, lambda: nextcloud.run_maintenance()) scheduler.add_task("schedule.vaultwarden_sync", settings.vaultwarden_sync_cron, run_vaultwarden_sync) - scheduler.add_task( - "schedule.keycloak_profile", - settings.keycloak_profile_cron, - run_profile_sync, - ) - scheduler.add_task( - "schedule.wger_user_sync", - settings.wger_user_sync_cron, - lambda: wger.sync_users(), - ) + scheduler.add_task("schedule.keycloak_profile", settings.keycloak_profile_cron, run_profile_sync) + scheduler.add_task("schedule.wger_user_sync", settings.wger_user_sync_cron, lambda: wger.sync_users()) scheduler.add_task("schedule.wger_admin", settings.wger_admin_cron, lambda: wger.ensure_admin(wait=False)) - scheduler.add_task( - "schedule.firefly_user_sync", - settings.firefly_user_sync_cron, - lambda: firefly.sync_users(), - ) - scheduler.add_task( - "schedule.firefly_cron", - settings.firefly_cron, - lambda: firefly.run_cron(), - ) - scheduler.add_task( - "schedule.pod_cleaner", - settings.pod_cleaner_cron, - clean_finished_pods, - ) - scheduler.add_task( - "schedule.opensearch_prune", - settings.opensearch_prune_cron, - prune_indices, - ) - scheduler.add_task( - "schedule.image_sweeper", - settings.image_sweeper_cron, - lambda: image_sweeper.run(wait=True), - ) - scheduler.add_task( - "schedule.metis_sentinel_watch", - settings.metis_sentinel_watch_cron, - lambda: metis.watch_sentinel(), - ) - scheduler.add_task( - "schedule.metis_k3s_token_sync", - settings.metis_k3s_token_sync_cron, - lambda: metis_token_sync.run(wait=True), - ) + scheduler.add_task("schedule.firefly_user_sync", settings.firefly_user_sync_cron, lambda: firefly.sync_users()) + scheduler.add_task("schedule.firefly_cron", settings.firefly_cron, lambda: firefly.run_cron()) + scheduler.add_task("schedule.pod_cleaner", settings.pod_cleaner_cron, clean_finished_pods) + scheduler.add_task("schedule.opensearch_prune", settings.opensearch_prune_cron, prune_indices) + scheduler.add_task("schedule.image_sweeper", settings.image_sweeper_cron, lambda: image_sweeper.run(wait=True)) + scheduler.add_task("schedule.metis_sentinel_watch", settings.metis_sentinel_watch_cron, lambda: metis.watch_sentinel()) + scheduler.add_task("schedule.metis_k3s_token_sync", settings.metis_k3s_token_sync_cron, lambda: metis_token_sync.run(wait=True)) scheduler.add_task( "schedule.platform_quality_suite_probe", settings.platform_quality_suite_probe_cron, lambda: platform_quality_probe.run(wait=True), ) - scheduler.add_task( - "schedule.jenkins_build_weather", - settings.jenkins_build_weather_cron, - collect_jenkins_build_weather, - ) - scheduler.add_task( - "schedule.jenkins_workspace_cleanup", - settings.jenkins_workspace_cleanup_cron, - cleanup_jenkins_workspace_storage, - ) - scheduler.add_task( - "schedule.vault_k8s_auth", - settings.vault_k8s_auth_cron, - lambda: vault.sync_k8s_auth(wait=True), - ) - scheduler.add_task( - "schedule.vault_oidc", - settings.vault_oidc_cron, - lambda: vault.sync_oidc(wait=True), - ) - scheduler.add_task( - "schedule.comms_guest_name", - settings.comms_guest_name_cron, - lambda: comms.run_guest_name_randomizer(wait=True), - ) - scheduler.add_task( - "schedule.comms_pin_invite", - settings.comms_pin_invite_cron, - lambda: comms.run_pin_invite(wait=True), - ) - scheduler.add_task( - "schedule.comms_reset_room", - settings.comms_reset_room_cron, - lambda: comms.run_reset_room(wait=True), - ) - scheduler.add_task( - "schedule.comms_seed_room", - settings.comms_seed_room_cron, - lambda: comms.run_seed_room(wait=True), - ) - scheduler.add_task( - "schedule.cluster_state", - settings.cluster_state_cron, - lambda: run_cluster_state(storage), - ) + scheduler.add_task("schedule.jenkins_build_weather", settings.jenkins_build_weather_cron, collect_jenkins_build_weather) + scheduler.add_task("schedule.jenkins_workspace_cleanup", settings.jenkins_workspace_cleanup_cron, cleanup_jenkins_workspace_storage) + scheduler.add_task("schedule.vault_k8s_auth", settings.vault_k8s_auth_cron, lambda: vault.sync_k8s_auth(wait=True)) + scheduler.add_task("schedule.vault_oidc", settings.vault_oidc_cron, lambda: vault.sync_oidc(wait=True)) + scheduler.add_task("schedule.comms_guest_name", settings.comms_guest_name_cron, lambda: comms.run_guest_name_randomizer(wait=True)) + scheduler.add_task("schedule.comms_pin_invite", settings.comms_pin_invite_cron, lambda: comms.run_pin_invite(wait=True)) + scheduler.add_task("schedule.comms_reset_room", settings.comms_reset_room_cron, lambda: comms.run_reset_room(wait=True)) + scheduler.add_task("schedule.comms_seed_room", settings.comms_seed_room_cron, lambda: comms.run_seed_room(wait=True)) + scheduler.add_task("schedule.cluster_state", settings.cluster_state_cron, lambda: run_cluster_state(storage)) scheduler.start() logger.info( "ariadne started", @@ -435,614 +243,13 @@ def metrics() -> Response: return Response(payload, media_type=CONTENT_TYPE_LATEST) -@app.get("/api/admin/access/requests") -def list_access_requests(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Return pending access requests for authenticated administrators.""" - - _require_admin(ctx) - logger.info( - "list access requests", - extra={"event": "access_requests_list", "actor": ctx.username or ""}, - ) - try: - rows = 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.""" - - _require_admin(ctx) - flags = settings.allowed_flag_groups - if keycloak_admin.ready(): - try: - flags = keycloak_admin.list_group_names(exclude={"admin"}) - except Exception: - flags = 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.""" - - _require_admin(ctx) - try: - rows = 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": _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.""" - - _require_admin(ctx) - try: - rows = 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": _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.""" - - _require_admin(ctx) - snapshot = 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.""" - - snapshot = 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.""" - - _require_admin(ctx) - with task_context("admin.access.approve"): - payload = await _read_json_payload(request) - allowed_flags = _allowed_flag_groups() - flags = [flag for flag in _flags_from_payload(payload) if flag in allowed_flags] - note = _note_from_payload(payload) - - decided_by = ctx.username or "" - try: - row = 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: - logger.info( - "access request approval ignored", - extra={"event": "access_request_approve", "actor": decided_by, "username": username, "status": "skipped"}, - ) - _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=provisioning.provision_access_request, - args=(request_code,), - daemon=True, - ).start() - logger.info( - "access request approved", - extra={ - "event": "access_request_approve", - "actor": decided_by, - "username": username, - "request_code": request_code, - }, - ) - _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.""" - - _require_admin(ctx) - with task_context("admin.access.deny"): - payload = await _read_json_payload(request) - note = _note_from_payload(payload) - decided_by = ctx.username or "" - - try: - row = 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: - logger.info( - "access request denial ignored", - extra={"event": "access_request_deny", "actor": decided_by, "username": username, "status": "skipped"}, - ) - _record_event( - "access_request_deny", - { - "actor": decided_by, - "username": username, - "status": "skipped", - }, - ) - return JSONResponse({"ok": True, "request_code": ""}) - logger.info( - "access request denied", - extra={ - "event": "access_request_deny", - "actor": decided_by, - "username": username, - "request_code": row.get("request_code") or "", - }, - ) - _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.""" - - code = (request_code or "").strip() - if not code: - raise HTTPException(status_code=400, detail="request_code is required") - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - try: - row = 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: - portal_db.execute( - "UPDATE access_requests SET provision_attempted_at = NULL WHERE request_code = %s", - (code,), - ) - 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=provisioning.provision_access_request, - args=(code,), - daemon=True, - ).start() - _record_event( - "access_request_retry", - { - "request_code": code, - "status": "ok", - }, - ) - return JSONResponse({"ok": True, "request_code": code}) - - -@app.post("/api/account/mailu/rotate") -def rotate_mailu_password(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Rotate the caller's Mailu app password and trigger dependent syncs.""" - - _require_account_access(ctx) - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - username = ctx.username or "" - if not username: - raise HTTPException(status_code=400, detail="missing username") - with task_context("account.mailu_rotate"): - started = datetime.now(timezone.utc) - status = "ok" - error_detail = "" - sync_enabled = mailu.ready() - sync_ok = False - sync_error = "" - nextcloud_sync: dict[str, Any] = {"status": "skipped"} - - logger.info( - "mailu password rotate requested", - extra={"event": "mailu_rotate", "username": username}, - ) - try: - password = random_password() - keycloak_admin.set_user_attribute(username, "mailu_app_password", password) - - if sync_enabled: - try: - mailu.sync("ariadne_mailu_rotate") - sync_ok = True - except Exception as exc: - sync_error = safe_error_detail(exc, "sync request failed") - - try: - nextcloud_sync = nextcloud.sync_mail(username, wait=True) - except Exception as exc: - nextcloud_sync = {"status": "error", "detail": safe_error_detail(exc, "failed to sync nextcloud")} - - logger.info( - "mailu password rotate completed", - extra={ - "event": "mailu_rotate", - "username": username, - "sync_enabled": sync_enabled, - "sync_ok": sync_ok, - "nextcloud_status": nextcloud_sync.get("status") if isinstance(nextcloud_sync, dict) else "", - }, - ) - return JSONResponse( - { - "password": password, - "sync_enabled": sync_enabled, - "sync_ok": sync_ok, - "sync_error": sync_error, - "nextcloud_sync": nextcloud_sync, - } - ) - except HTTPException as exc: - status = "error" - error_detail = str(exc.detail) - raise - except Exception as exc: - status = "error" - error_detail = safe_error_detail(exc, "mailu rotate failed") - raise HTTPException(status_code=502, detail=error_detail) - finally: - finished = datetime.now(timezone.utc) - duration_sec = (finished - started).total_seconds() - record_task_run("mailu_rotate", status, duration_sec) - try: - storage.record_task_run( - TaskRunRecord( - request_code=None, - task="mailu_rotate", - status=status, - detail=error_detail or None, - started_at=started, - finished_at=finished, - duration_ms=int(duration_sec * 1000), - ) - ) - except Exception: - pass - _record_event( - "mailu_rotate", - { - "username": username, - "status": status, - "sync_enabled": sync_enabled, - "sync_ok": sync_ok, - "nextcloud_status": nextcloud_sync.get("status") if isinstance(nextcloud_sync, dict) else "", - "error": error_detail, - }, - ) - - -@app.post("/api/account/wger/reset") -def reset_wger_password(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Reset the caller's Wger password and synchronize the service account.""" - - _require_account_access(ctx) - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - username = ctx.username or "" - if not username: - raise HTTPException(status_code=400, detail="missing username") - - with task_context("account.wger_reset"): - mailu_email = _resolve_mailu_email(username) - password = random_password() - request = PasswordResetRequest( - task_name="wger_reset", - service_label="wger", - username=username, - mailu_email=mailu_email, - password=password, - sync_fn=lambda: wger.sync_user(username, mailu_email, password, wait=True), - password_attr="wger_password", - updated_attr="wger_password_updated_at", - error_hint="wger sync failed", - ) - return _run_password_reset(request) - - -@app.post("/api/account/firefly/reset") -def reset_firefly_password(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Reset the caller's Firefly password and synchronize the service account.""" - - _require_account_access(ctx) - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - username = ctx.username or "" - if not username: - raise HTTPException(status_code=400, detail="missing username") - - with task_context("account.firefly_reset"): - mailu_email = _resolve_mailu_email(username) - password = random_password(24) - request = PasswordResetRequest( - task_name="firefly_reset", - service_label="firefly", - username=username, - mailu_email=mailu_email, - password=password, - sync_fn=lambda: firefly.sync_user(mailu_email, password, wait=True), - password_attr="firefly_password", - updated_attr="firefly_password_updated_at", - error_hint="firefly sync failed", - ) - return _run_password_reset(request) - - -@app.post("/api/account/firefly/rotation/check") -def firefly_rotation_check(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Check whether the caller's Firefly password rotation is healthy.""" - - _require_account_access(ctx) - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - username = ctx.username or "" - if not username: - raise HTTPException(status_code=400, detail="missing username") - - with task_context("account.firefly_rotation_check"): - result = firefly.check_rotation_for_user(username) - if result.get("status") == "error": - raise HTTPException(status_code=502, detail=result.get("detail") or "firefly rotation check failed") - return JSONResponse(result) - - -@app.post("/api/account/wger/rotation/check") -def wger_rotation_check(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Check whether the caller's Wger password rotation is healthy.""" - - _require_account_access(ctx) - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - username = ctx.username or "" - if not username: - raise HTTPException(status_code=400, detail="missing username") - - with task_context("account.wger_rotation_check"): - result = wger.check_rotation_for_user(username) - if result.get("status") == "error": - raise HTTPException(status_code=502, detail=result.get("detail") or "wger rotation check failed") - return JSONResponse(result) - - -@app.post("/api/account/nextcloud/mail/sync") -async def nextcloud_mail_sync(request: Request, ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Synchronize the caller's Mailu address into Nextcloud mail settings.""" - - _require_account_access(ctx) - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - username = ctx.username or "" - if not username: - raise HTTPException(status_code=400, detail="missing username") - - with task_context("account.nextcloud_sync"): - try: - payload = await request.json() - except Exception: - payload = {} - wait = bool(payload.get("wait", True)) if isinstance(payload, dict) else True - - started = datetime.now(timezone.utc) - status = "ok" - error_detail = "" - logger.info( - "nextcloud mail sync requested", - extra={"event": "nextcloud_sync", "username": username, "wait": wait}, - ) - try: - result = nextcloud.sync_mail(username, wait=wait) - logger.info( - "nextcloud mail sync completed", - extra={ - "event": "nextcloud_sync", - "username": username, - "status": result.get("status") if isinstance(result, dict) else "", - }, - ) - return JSONResponse(result) - except HTTPException as exc: - status = "error" - error_detail = str(exc.detail) - raise - except Exception as exc: - status = "error" - error_detail = safe_error_detail(exc, "failed to sync nextcloud mail") - logger.info( - "nextcloud mail sync failed", - extra={"event": "nextcloud_sync", "username": username, "error": error_detail}, - ) - raise HTTPException(status_code=502, detail=error_detail) - finally: - finished = datetime.now(timezone.utc) - duration_sec = (finished - started).total_seconds() - record_task_run("nextcloud_sync", status, duration_sec) - try: - storage.record_task_run( - TaskRunRecord( - request_code=None, - task="nextcloud_sync", - status=status, - detail=error_detail or None, - started_at=started, - finished_at=finished, - duration_ms=int(duration_sec * 1000), - ) - ) - except Exception: - pass - _record_event( - "nextcloud_sync", - { - "username": username, - "status": status, - "wait": wait, - "error": error_detail, - }, - ) - - @app.post("/events") def mailu_event_listener(payload: dict[str, Any] | None = Body(default=None)) -> Response: """Accept Mailu webhook events and dispatch mapped account actions.""" status_code, response = mailu_events.handle_event(payload) return JSONResponse(response, status_code=status_code) + + +_register_admin_routes(app, _require_auth, _app_module) +_register_account_routes(app, _require_auth, _app_module) diff --git a/ariadne/app_account_routes.py b/ariadne/app_account_routes.py new file mode 100644 index 0000000..5b5955a --- /dev/null +++ b/ariadne/app_account_routes.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Callable + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse + +from .auth.keycloak import AuthContext +from .db.storage import TaskRunRecord +from .utils.errors import safe_error_detail +from .utils.logging import task_context + + +@dataclass(frozen=True) +class AccountTaskContext: + task_name: str + username: str + started: datetime + extra: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class PasswordResetRequest: + task_name: str + service_label: str + username: str + mailu_email: str + password: str + sync_fn: Callable[[], dict[str, Any]] + password_attr: str + updated_attr: str + error_hint: str + + +def _resolve_mailu_email(module: Any, username: str) -> str: + mailu_email = f"{username}@{module.settings.mailu_domain}" + try: + user = module.keycloak_admin.find_user(username) or {} + attrs = user.get("attributes") if isinstance(user, dict) else None + if isinstance(attrs, dict): + raw_mailu = attrs.get("mailu_email") + if isinstance(raw_mailu, list) and raw_mailu: + return str(raw_mailu[0]) + if isinstance(raw_mailu, str) and raw_mailu: + return raw_mailu + except Exception: + return mailu_email + return mailu_email + + +def _record_account_task(module: Any, ctx: AccountTaskContext, status: str, error_detail: str) -> None: + finished = datetime.now(timezone.utc) + duration_sec = (finished - ctx.started).total_seconds() + module.record_task_run(ctx.task_name, status, duration_sec) + try: + module.storage.record_task_run( + TaskRunRecord( + request_code=None, + task=ctx.task_name, + status=status, + detail=error_detail or None, + started_at=ctx.started, + finished_at=finished, + duration_ms=int(duration_sec * 1000), + ) + ) + except Exception: + pass + detail = {"username": ctx.username, "status": status, "error": error_detail} + if ctx.extra: + detail.update(ctx.extra) + module._record_event(ctx.task_name, detail) + + +def _run_password_reset(module: Any, request: PasswordResetRequest) -> JSONResponse: + started = datetime.now(timezone.utc) + task_ctx = AccountTaskContext( + task_name=request.task_name, + username=request.username, + started=started, + extra={"mailu_email": request.mailu_email}, + ) + status = "ok" + error_detail = "" + module.logger.info( + f"{request.service_label} password reset requested", + extra={"event": request.task_name, "username": request.username}, + ) + try: + result = request.sync_fn() + status_val = result.get("status") if isinstance(result, dict) else "error" + if status_val != "ok": + raise RuntimeError(f"{request.service_label} sync {status_val}") + + module.keycloak_admin.set_user_attribute(request.username, request.password_attr, request.password) + module.keycloak_admin.set_user_attribute( + request.username, + request.updated_attr, + datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + module.logger.info( + f"{request.service_label} password reset completed", + extra={"event": request.task_name, "username": request.username}, + ) + return JSONResponse({"status": "ok", "password": request.password}) + except HTTPException as exc: + status = "error" + error_detail = str(exc.detail) + raise + except Exception as exc: + status = "error" + error_detail = safe_error_detail(exc, request.error_hint) + raise HTTPException(status_code=502, detail=error_detail) + finally: + _record_account_task(module, task_ctx, status, error_detail) + + +def _register_account_routes(app: FastAPI, require_auth: Callable, deps: Callable[[], Any]) -> None: # noqa: PLR0915 + @app.post("/api/account/mailu/rotate") + def rotate_mailu_password(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Rotate the caller's Mailu app password and trigger dependent syncs.""" + + module = deps() + module._require_account_access(ctx) + if not module.keycloak_admin.ready(): + raise HTTPException(status_code=503, detail="server not configured") + + username = ctx.username or "" + if not username: + raise HTTPException(status_code=400, detail="missing username") + with task_context("account.mailu_rotate"): + started = datetime.now(timezone.utc) + status = "ok" + error_detail = "" + sync_enabled = module.mailu.ready() + sync_ok = False + sync_error = "" + nextcloud_sync: dict[str, Any] = {"status": "skipped"} + + module.logger.info("mailu password rotate requested", extra={"event": "mailu_rotate", "username": username}) + try: + password = module.random_password() + module.keycloak_admin.set_user_attribute(username, "mailu_app_password", password) + + if sync_enabled: + try: + module.mailu.sync("ariadne_mailu_rotate") + sync_ok = True + except Exception as exc: + sync_error = safe_error_detail(exc, "sync request failed") + + try: + nextcloud_sync = module.nextcloud.sync_mail(username, wait=True) + except Exception as exc: + nextcloud_sync = {"status": "error", "detail": safe_error_detail(exc, "failed to sync nextcloud")} + + module.logger.info( + "mailu password rotate completed", + extra={ + "event": "mailu_rotate", + "username": username, + "sync_enabled": sync_enabled, + "sync_ok": sync_ok, + "nextcloud_status": nextcloud_sync.get("status") if isinstance(nextcloud_sync, dict) else "", + }, + ) + return JSONResponse( + { + "password": password, + "sync_enabled": sync_enabled, + "sync_ok": sync_ok, + "sync_error": sync_error, + "nextcloud_sync": nextcloud_sync, + } + ) + except HTTPException as exc: + status = "error" + error_detail = str(exc.detail) + raise + except Exception as exc: + status = "error" + error_detail = safe_error_detail(exc, "mailu rotate failed") + raise HTTPException(status_code=502, detail=error_detail) + finally: + task_ctx = AccountTaskContext("mailu_rotate", username, started) + _record_account_task(module, task_ctx, status, error_detail) + module._record_event( + "mailu_rotate", + { + "username": username, + "status": status, + "sync_enabled": sync_enabled, + "sync_ok": sync_ok, + "nextcloud_status": nextcloud_sync.get("status") if isinstance(nextcloud_sync, dict) else "", + "error": error_detail, + }, + ) + + @app.post("/api/account/wger/reset") + def reset_wger_password(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Reset the caller's Wger password and synchronize the service account.""" + + module = deps() + module._require_account_access(ctx) + if not module.keycloak_admin.ready(): + raise HTTPException(status_code=503, detail="server not configured") + + username = ctx.username or "" + if not username: + raise HTTPException(status_code=400, detail="missing username") + + with task_context("account.wger_reset"): + mailu_email = _resolve_mailu_email(module, username) + password = module.random_password() + request = PasswordResetRequest( + task_name="wger_reset", + service_label="wger", + username=username, + mailu_email=mailu_email, + password=password, + sync_fn=lambda: module.wger.sync_user(username, mailu_email, password, wait=True), + password_attr="wger_password", + updated_attr="wger_password_updated_at", + error_hint="wger sync failed", + ) + return _run_password_reset(module, request) + + @app.post("/api/account/firefly/reset") + def reset_firefly_password(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Reset the caller's Firefly password and synchronize the service account.""" + + module = deps() + module._require_account_access(ctx) + if not module.keycloak_admin.ready(): + raise HTTPException(status_code=503, detail="server not configured") + + username = ctx.username or "" + if not username: + raise HTTPException(status_code=400, detail="missing username") + + with task_context("account.firefly_reset"): + mailu_email = _resolve_mailu_email(module, username) + password = module.random_password(24) + request = PasswordResetRequest( + task_name="firefly_reset", + service_label="firefly", + username=username, + mailu_email=mailu_email, + password=password, + sync_fn=lambda: module.firefly.sync_user(mailu_email, password, wait=True), + password_attr="firefly_password", + updated_attr="firefly_password_updated_at", + error_hint="firefly sync failed", + ) + return _run_password_reset(module, request) + + @app.post("/api/account/firefly/rotation/check") + def firefly_rotation_check(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Check whether the caller's Firefly password rotation is healthy.""" + + module = deps() + module._require_account_access(ctx) + if not module.keycloak_admin.ready(): + raise HTTPException(status_code=503, detail="server not configured") + + username = ctx.username or "" + if not username: + raise HTTPException(status_code=400, detail="missing username") + + with task_context("account.firefly_rotation_check"): + result = module.firefly.check_rotation_for_user(username) + if result.get("status") == "error": + raise HTTPException(status_code=502, detail=result.get("detail") or "firefly rotation check failed") + return JSONResponse(result) + + @app.post("/api/account/wger/rotation/check") + def wger_rotation_check(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Check whether the caller's Wger password rotation is healthy.""" + + module = deps() + module._require_account_access(ctx) + if not module.keycloak_admin.ready(): + raise HTTPException(status_code=503, detail="server not configured") + + username = ctx.username or "" + if not username: + raise HTTPException(status_code=400, detail="missing username") + + with task_context("account.wger_rotation_check"): + result = module.wger.check_rotation_for_user(username) + if result.get("status") == "error": + raise HTTPException(status_code=502, detail=result.get("detail") or "wger rotation check failed") + return JSONResponse(result) + + @app.post("/api/account/nextcloud/mail/sync") + async def nextcloud_mail_sync(request: Request, ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Synchronize the caller's Mailu address into Nextcloud mail settings.""" + + module = deps() + module._require_account_access(ctx) + if not module.keycloak_admin.ready(): + raise HTTPException(status_code=503, detail="server not configured") + + username = ctx.username or "" + if not username: + raise HTTPException(status_code=400, detail="missing username") + + with task_context("account.nextcloud_sync"): + try: + payload = await request.json() + except Exception: + payload = {} + wait = bool(payload.get("wait", True)) if isinstance(payload, dict) else True + + started = datetime.now(timezone.utc) + status = "ok" + error_detail = "" + module.logger.info("nextcloud mail sync requested", extra={"event": "nextcloud_sync", "username": username, "wait": wait}) + try: + result = module.nextcloud.sync_mail(username, wait=wait) + module.logger.info( + "nextcloud mail sync completed", + extra={ + "event": "nextcloud_sync", + "username": username, + "status": result.get("status") if isinstance(result, dict) else "", + }, + ) + return JSONResponse(result) + except HTTPException as exc: + status = "error" + error_detail = str(exc.detail) + raise + except Exception as exc: + status = "error" + error_detail = safe_error_detail(exc, "failed to sync nextcloud mail") + module.logger.info( + "nextcloud mail sync failed", + extra={"event": "nextcloud_sync", "username": username, "error": error_detail}, + ) + raise HTTPException(status_code=502, detail=error_detail) + finally: + task_ctx = AccountTaskContext("nextcloud_sync", username, started) + _record_account_task(module, task_ctx, status, error_detail) + module._record_event( + "nextcloud_sync", + { + "username": username, + "status": status, + "wait": wait, + "error": error_detail, + }, + ) diff --git a/ariadne/app_admin_routes.py b/ariadne/app_admin_routes.py new file mode 100644 index 0000000..b87723a --- /dev/null +++ b/ariadne/app_admin_routes.py @@ -0,0 +1,346 @@ +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}) diff --git a/ci/loc_hygiene_waivers.tsv b/ci/loc_hygiene_waivers.tsv index 6d5a9bc..536cc49 100644 --- a/ci/loc_hygiene_waivers.tsv +++ b/ci/loc_hygiene_waivers.tsv @@ -1,6 +1,5 @@ # path reason ariadne/services/cluster_state.py split planned; service orchestration decomposition tracked in hygiene backlog -ariadne/app.py split planned; Flask app bootstrap/routes currently co-located tests/test_provisioning.py test module split planned; broad provisioning coverage retained meanwhile tests/test_services.py test module split planned; broad service contract coverage retained meanwhile tests/test_app.py test module split planned; API coverage retained meanwhile