refactor(ariadne): split app route registration
This commit is contained in:
parent
18a6471c08
commit
0fa6138612
871
ariadne/app.py
871
ariadne/app.py
@ -1,71 +1,49 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
import json
|
||||||
import threading
|
import sys
|
||||||
from typing import Any, Callable
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request
|
from fastapi import Body, Depends, FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
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 .auth.keycloak import AuthContext, authenticator
|
||||||
from .db.database import Database, DatabaseConfig
|
from .db.database import Database, DatabaseConfig
|
||||||
from .db.storage import Storage, TaskRunRecord
|
from .db.storage import Storage
|
||||||
from .manager.provisioning import ProvisioningManager
|
from .manager.provisioning import ProvisioningManager
|
||||||
from .metrics.metrics import record_task_run
|
from .metrics.metrics import record_task_run
|
||||||
from .scheduler.cron import CronScheduler
|
from .scheduler.cron import CronScheduler
|
||||||
from .services.cluster_state import run_cluster_state
|
from .services.cluster_state import run_cluster_state
|
||||||
from .services.comms import comms
|
from .services.comms import comms
|
||||||
from .services.firefly import firefly
|
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_admin import keycloak_admin
|
||||||
from .services.keycloak_profile import run_profile_sync
|
from .services.keycloak_profile import run_profile_sync
|
||||||
from .services.mailu import mailu
|
from .services.mailu import mailu
|
||||||
from .services.mailu_events import mailu_events
|
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 import metis
|
||||||
from .services.metis_token_sync import metis_token_sync
|
from .services.metis_token_sync import metis_token_sync
|
||||||
|
from .services.nextcloud import nextcloud
|
||||||
from .services.opensearch_prune import prune_indices
|
from .services.opensearch_prune import prune_indices
|
||||||
from .services.platform_quality_probe import platform_quality_probe
|
from .services.platform_quality_probe import platform_quality_probe
|
||||||
from .services.pod_cleaner import clean_finished_pods
|
from .services.pod_cleaner import clean_finished_pods
|
||||||
from .services.vaultwarden_sync import run_vaultwarden_sync
|
|
||||||
from .services.vault import vault
|
from .services.vault import vault
|
||||||
|
from .services.vaultwarden_sync import run_vaultwarden_sync
|
||||||
from .services.wger import wger
|
from .services.wger import wger
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
from .utils.errors import safe_error_detail
|
|
||||||
from .utils.http import extract_bearer_token
|
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
|
from .utils.passwords import random_password
|
||||||
|
|
||||||
|
|
||||||
configure_logging(LogConfig(level=settings.log_level))
|
configure_logging(LogConfig(level=settings.log_level))
|
||||||
logger = get_logger(__name__)
|
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(
|
portal_db = Database(
|
||||||
settings.portal_database_url,
|
settings.portal_database_url,
|
||||||
DatabaseConfig(
|
DatabaseConfig(
|
||||||
@ -93,6 +71,7 @@ ariadne_db = Database(
|
|||||||
storage = Storage(ariadne_db, portal_db)
|
storage = Storage(ariadne_db, portal_db)
|
||||||
provisioning = ProvisioningManager(portal_db, storage)
|
provisioning = ProvisioningManager(portal_db, storage)
|
||||||
scheduler = CronScheduler(storage, settings.schedule_tick_sec)
|
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:
|
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
|
return detail
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title=settings.app_name)
|
|
||||||
|
|
||||||
|
|
||||||
def _require_auth(request: Request) -> AuthContext:
|
def _require_auth(request: Request) -> AuthContext:
|
||||||
token = extract_bearer_token(request)
|
token = extract_bearer_token(request)
|
||||||
if not token:
|
if not token:
|
||||||
@ -169,92 +145,8 @@ def _allowed_flag_groups() -> list[str]:
|
|||||||
return settings.allowed_flag_groups
|
return settings.allowed_flag_groups
|
||||||
|
|
||||||
|
|
||||||
def _resolve_mailu_email(username: str) -> str:
|
def _app_module() -> Any:
|
||||||
mailu_email = f"{username}@{settings.mailu_domain}"
|
return sys.modules[__name__]
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
@ -262,118 +154,34 @@ def _startup() -> None:
|
|||||||
provisioning.start()
|
provisioning.start()
|
||||||
|
|
||||||
scheduler.add_task("schedule.mailu_sync", settings.mailu_sync_cron, lambda: mailu.sync("ariadne_schedule"))
|
scheduler.add_task("schedule.mailu_sync", settings.mailu_sync_cron, lambda: mailu.sync("ariadne_schedule"))
|
||||||
scheduler.add_task(
|
scheduler.add_task("schedule.nextcloud_sync", settings.nextcloud_sync_cron, lambda: nextcloud.sync_mail(wait=False))
|
||||||
"schedule.nextcloud_sync",
|
scheduler.add_task("schedule.nextcloud_cron", settings.nextcloud_cron, lambda: nextcloud.run_cron())
|
||||||
settings.nextcloud_sync_cron,
|
scheduler.add_task("schedule.nextcloud_maintenance", settings.nextcloud_maintenance_cron, lambda: nextcloud.run_maintenance())
|
||||||
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.vaultwarden_sync", settings.vaultwarden_sync_cron, run_vaultwarden_sync)
|
||||||
scheduler.add_task(
|
scheduler.add_task("schedule.keycloak_profile", settings.keycloak_profile_cron, run_profile_sync)
|
||||||
"schedule.keycloak_profile",
|
scheduler.add_task("schedule.wger_user_sync", settings.wger_user_sync_cron, lambda: wger.sync_users())
|
||||||
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.wger_admin", settings.wger_admin_cron, lambda: wger.ensure_admin(wait=False))
|
||||||
scheduler.add_task(
|
scheduler.add_task("schedule.firefly_user_sync", settings.firefly_user_sync_cron, lambda: firefly.sync_users())
|
||||||
"schedule.firefly_user_sync",
|
scheduler.add_task("schedule.firefly_cron", settings.firefly_cron, lambda: firefly.run_cron())
|
||||||
settings.firefly_user_sync_cron,
|
scheduler.add_task("schedule.pod_cleaner", settings.pod_cleaner_cron, clean_finished_pods)
|
||||||
lambda: firefly.sync_users(),
|
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(
|
scheduler.add_task("schedule.metis_sentinel_watch", settings.metis_sentinel_watch_cron, lambda: metis.watch_sentinel())
|
||||||
"schedule.firefly_cron",
|
scheduler.add_task("schedule.metis_k3s_token_sync", settings.metis_k3s_token_sync_cron, lambda: metis_token_sync.run(wait=True))
|
||||||
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(
|
scheduler.add_task(
|
||||||
"schedule.platform_quality_suite_probe",
|
"schedule.platform_quality_suite_probe",
|
||||||
settings.platform_quality_suite_probe_cron,
|
settings.platform_quality_suite_probe_cron,
|
||||||
lambda: platform_quality_probe.run(wait=True),
|
lambda: platform_quality_probe.run(wait=True),
|
||||||
)
|
)
|
||||||
scheduler.add_task(
|
scheduler.add_task("schedule.jenkins_build_weather", settings.jenkins_build_weather_cron, collect_jenkins_build_weather)
|
||||||
"schedule.jenkins_build_weather",
|
scheduler.add_task("schedule.jenkins_workspace_cleanup", settings.jenkins_workspace_cleanup_cron, cleanup_jenkins_workspace_storage)
|
||||||
settings.jenkins_build_weather_cron,
|
scheduler.add_task("schedule.vault_k8s_auth", settings.vault_k8s_auth_cron, lambda: vault.sync_k8s_auth(wait=True))
|
||||||
collect_jenkins_build_weather,
|
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(
|
scheduler.add_task("schedule.comms_pin_invite", settings.comms_pin_invite_cron, lambda: comms.run_pin_invite(wait=True))
|
||||||
"schedule.jenkins_workspace_cleanup",
|
scheduler.add_task("schedule.comms_reset_room", settings.comms_reset_room_cron, lambda: comms.run_reset_room(wait=True))
|
||||||
settings.jenkins_workspace_cleanup_cron,
|
scheduler.add_task("schedule.comms_seed_room", settings.comms_seed_room_cron, lambda: comms.run_seed_room(wait=True))
|
||||||
cleanup_jenkins_workspace_storage,
|
scheduler.add_task("schedule.cluster_state", settings.cluster_state_cron, lambda: run_cluster_state(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()
|
scheduler.start()
|
||||||
logger.info(
|
logger.info(
|
||||||
"ariadne started",
|
"ariadne started",
|
||||||
@ -435,614 +243,13 @@ def metrics() -> Response:
|
|||||||
return Response(payload, media_type=CONTENT_TYPE_LATEST)
|
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")
|
@app.post("/events")
|
||||||
def mailu_event_listener(payload: dict[str, Any] | None = Body(default=None)) -> Response:
|
def mailu_event_listener(payload: dict[str, Any] | None = Body(default=None)) -> Response:
|
||||||
"""Accept Mailu webhook events and dispatch mapped account actions."""
|
"""Accept Mailu webhook events and dispatch mapped account actions."""
|
||||||
|
|
||||||
status_code, response = mailu_events.handle_event(payload)
|
status_code, response = mailu_events.handle_event(payload)
|
||||||
return JSONResponse(response, status_code=status_code)
|
return JSONResponse(response, status_code=status_code)
|
||||||
|
|
||||||
|
|
||||||
|
_register_admin_routes(app, _require_auth, _app_module)
|
||||||
|
_register_account_routes(app, _require_auth, _app_module)
|
||||||
|
|||||||
356
ariadne/app_account_routes.py
Normal file
356
ariadne/app_account_routes.py
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
346
ariadne/app_admin_routes.py
Normal file
346
ariadne/app_admin_routes.py
Normal file
@ -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})
|
||||||
@ -1,6 +1,5 @@
|
|||||||
# path reason
|
# path reason
|
||||||
ariadne/services/cluster_state.py split planned; service orchestration decomposition tracked in hygiene backlog
|
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_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_services.py test module split planned; broad service contract coverage retained meanwhile
|
||||||
tests/test_app.py test module split planned; API coverage retained meanwhile
|
tests/test_app.py test module split planned; API coverage retained meanwhile
|
||||||
|
|||||||
|
Loading…
x
Reference in New Issue
Block a user