from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone import hashlib import threading import time from typing import Any from ..db.database import Database from ..db.storage import REQUIRED_TASKS, Storage from ..metrics.metrics import record_task_run, set_access_request_counts from ..services.firefly import firefly from ..services.keycloak_admin import keycloak_admin from ..services.mailer import MailerError, mailer from ..services.mailu import mailu from ..services.nextcloud import nextcloud from ..services.vaultwarden import vaultwarden from ..services.wger import wger from ..settings import settings from ..utils.errors import safe_error_detail from ..utils.logging import get_logger from ..utils.passwords import random_password MAILU_EMAIL_ATTR = "mailu_email" MAILU_APP_PASSWORD_ATTR = "mailu_app_password" MAILU_ENABLED_ATTR = "mailu_enabled" WGER_PASSWORD_ATTR = "wger_password" WGER_PASSWORD_UPDATED_ATTR = "wger_password_updated_at" FIREFLY_PASSWORD_ATTR = "firefly_password" FIREFLY_PASSWORD_UPDATED_ATTR = "firefly_password_updated_at" logger = get_logger(__name__) @dataclass(frozen=True) class ProvisionOutcome: ok: bool status: str def _advisory_lock_id(request_code: str) -> int: digest = hashlib.sha256(request_code.encode("utf-8")).digest() return int.from_bytes(digest[:8], "big", signed=True) def _extract_attr(attrs: Any, key: str) -> str: if not isinstance(attrs, dict): return "" raw = attrs.get(key) if isinstance(raw, list): for item in raw: if isinstance(item, str) and item.strip(): return item.strip() return "" if isinstance(raw, str) and raw.strip(): return raw.strip() return "" class ProvisioningManager: def __init__(self, db: Database, storage: Storage) -> None: self._db = db self._storage = storage self._thread: threading.Thread | None = None self._stop_event = threading.Event() def start(self) -> None: if self._thread and self._thread.is_alive(): return self._stop_event.clear() self._thread = threading.Thread(target=self._run_loop, name="ariadne-provision", daemon=True) self._thread.start() def stop(self) -> None: self._stop_event.set() if self._thread: self._thread.join(timeout=5) def _run_loop(self) -> None: while not self._stop_event.is_set(): try: self._sync_status_metrics() except Exception: pass if not keycloak_admin.ready(): time.sleep(settings.provision_poll_interval_sec) continue candidates = self._storage.list_provision_candidates() for request in candidates: self.provision_access_request(request.request_code) time.sleep(settings.provision_poll_interval_sec) def _sync_status_metrics(self) -> None: counts = self._db.fetchall( "SELECT status, COUNT(*) AS count FROM access_requests GROUP BY status" ) payload: dict[str, int] = {} for row in counts: status = row.get("status") count = row.get("count") if isinstance(status, str) and isinstance(count, int): payload[status] = count set_access_request_counts(payload) def provision_access_request(self, request_code: str) -> ProvisionOutcome: if not request_code: return ProvisionOutcome(ok=False, status="unknown") if not keycloak_admin.ready(): return ProvisionOutcome(ok=False, status="accounts_building") required_tasks = list(REQUIRED_TASKS) logger.info( "provisioning started", extra={"event": "provision_start", "request_code": request_code}, ) with self._db.connection() as conn: lock_id = _advisory_lock_id(request_code) locked_row = conn.execute("SELECT pg_try_advisory_lock(%s) AS locked", (lock_id,)).fetchone() if not locked_row or not locked_row.get("locked"): return ProvisionOutcome(ok=False, status="accounts_building") try: row = conn.execute( """ SELECT username, contact_email, email_verified_at, status, initial_password, initial_password_revealed_at, provision_attempted_at, approval_flags FROM access_requests WHERE request_code = %s """, (request_code,), ).fetchone() if not row: return ProvisionOutcome(ok=False, status="unknown") username = str(row.get("username") or "") contact_email = str(row.get("contact_email") or "") email_verified_at = row.get("email_verified_at") status = str(row.get("status") or "") initial_password = row.get("initial_password") revealed_at = row.get("initial_password_revealed_at") attempted_at = row.get("provision_attempted_at") approval_flags = row.get("approval_flags") if isinstance(row.get("approval_flags"), list) else [] if status == "approved": conn.execute( """ UPDATE access_requests SET status = 'accounts_building' WHERE request_code = %s AND status = 'approved' """, (request_code,), ) status = "accounts_building" if status not in {"accounts_building", "awaiting_onboarding", "ready"}: return ProvisionOutcome(ok=False, status=status or "unknown") self._ensure_task_rows(conn, request_code, required_tasks) if status == "accounts_building": now = datetime.now(timezone.utc) if isinstance(attempted_at, datetime): if attempted_at.tzinfo is None: attempted_at = attempted_at.replace(tzinfo=timezone.utc) age_sec = (now - attempted_at).total_seconds() if age_sec < settings.provision_retry_cooldown_sec: return ProvisionOutcome(ok=False, status="accounts_building") conn.execute( "UPDATE access_requests SET provision_attempted_at = NOW() WHERE request_code = %s", (request_code,), ) user_id = "" mailu_email = f"{username}@{settings.mailu_domain}" # Task: ensure Keycloak user exists start = datetime.now(timezone.utc) try: user = keycloak_admin.find_user(username) if not user: if not isinstance(email_verified_at, datetime): raise RuntimeError("missing verified email address") email = contact_email.strip() if not email: raise RuntimeError("missing verified email address") existing_email_user = keycloak_admin.find_user_by_email(email) if existing_email_user and (existing_email_user.get("username") or "") != username: raise RuntimeError("email is already associated with an existing Atlas account") payload = { "username": username, "enabled": True, "email": email, "emailVerified": True, "requiredActions": [], "attributes": { MAILU_EMAIL_ATTR: [mailu_email], MAILU_ENABLED_ATTR: ["true"], }, } created_id = keycloak_admin.create_user(payload) user = keycloak_admin.get_user(created_id) user_id = str((user or {}).get("id") or "") if not user_id: raise RuntimeError("user id missing") try: full = keycloak_admin.get_user(user_id) attrs = full.get("attributes") or {} actions = full.get("requiredActions") if isinstance(actions, list) and "CONFIGURE_TOTP" in actions: new_actions = [a for a in actions if a != "CONFIGURE_TOTP"] keycloak_admin.update_user_safe(user_id, {"requiredActions": new_actions}) if isinstance(attrs, dict): existing = _extract_attr(attrs, MAILU_EMAIL_ATTR) if existing: mailu_email = existing else: mailu_email = f"{username}@{settings.mailu_domain}" keycloak_admin.set_user_attribute(username, MAILU_EMAIL_ATTR, mailu_email) enabled_value = _extract_attr(attrs, MAILU_ENABLED_ATTR) if enabled_value.lower() not in {"1", "true", "yes", "y", "on"}: keycloak_admin.set_user_attribute(username, MAILU_ENABLED_ATTR, "true") except Exception: mailu_email = f"{username}@{settings.mailu_domain}" self._upsert_task(conn, request_code, "keycloak_user", "ok", None) self._record_task(request_code, "keycloak_user", "ok", None, start) except Exception as exc: detail = safe_error_detail(exc, "failed to ensure user") self._upsert_task(conn, request_code, "keycloak_user", "error", detail) self._record_task(request_code, "keycloak_user", "error", detail, start) if not user_id: return ProvisionOutcome(ok=False, status="accounts_building") # Task: set initial password for Keycloak start = datetime.now(timezone.utc) try: should_reset = status == "accounts_building" and revealed_at is None password_value: str | None = None if should_reset: if isinstance(initial_password, str) and initial_password: password_value = initial_password elif initial_password is None: password_value = random_password(20) conn.execute( """ UPDATE access_requests SET initial_password = %s WHERE request_code = %s AND initial_password IS NULL """, (password_value, request_code), ) initial_password = password_value if password_value: keycloak_admin.reset_password(user_id, password_value, temporary=False) if isinstance(initial_password, str) and initial_password: self._upsert_task(conn, request_code, "keycloak_password", "ok", None) self._record_task(request_code, "keycloak_password", "ok", None, start) elif revealed_at is not None: detail = "initial password already revealed" self._upsert_task(conn, request_code, "keycloak_password", "ok", detail) self._record_task(request_code, "keycloak_password", "ok", detail, start) else: raise RuntimeError("initial password missing") except Exception as exc: detail = safe_error_detail(exc, "failed to set password") self._upsert_task(conn, request_code, "keycloak_password", "error", detail) self._record_task(request_code, "keycloak_password", "error", detail, start) # Task: group membership start = datetime.now(timezone.utc) try: approved_flags = [flag for flag in approval_flags if flag in settings.allowed_flag_groups] groups = list(dict.fromkeys(settings.default_user_groups + approved_flags)) for group_name in groups: gid = keycloak_admin.get_group_id(group_name) if not gid: raise RuntimeError(f"group missing: {group_name}") keycloak_admin.add_user_to_group(user_id, gid) self._upsert_task(conn, request_code, "keycloak_groups", "ok", None) self._record_task(request_code, "keycloak_groups", "ok", None, start) except Exception as exc: detail = safe_error_detail(exc, "failed to add groups") self._upsert_task(conn, request_code, "keycloak_groups", "error", detail) self._record_task(request_code, "keycloak_groups", "error", detail, start) # Task: ensure mailu app password exists start = datetime.now(timezone.utc) try: full = keycloak_admin.get_user(user_id) attrs = full.get("attributes") or {} existing = _extract_attr(attrs, MAILU_APP_PASSWORD_ATTR) if not existing: keycloak_admin.set_user_attribute(username, MAILU_APP_PASSWORD_ATTR, random_password()) self._upsert_task(conn, request_code, "mailu_app_password", "ok", None) self._record_task(request_code, "mailu_app_password", "ok", None, start) except Exception as exc: detail = safe_error_detail(exc, "failed to set mail password") self._upsert_task(conn, request_code, "mailu_app_password", "error", detail) self._record_task(request_code, "mailu_app_password", "error", detail, start) # Task: trigger Mailu sync start = datetime.now(timezone.utc) mailbox_ready = True try: if not settings.mailu_sync_url: detail = "sync disabled" self._upsert_task(conn, request_code, "mailu_sync", "ok", detail) self._record_task(request_code, "mailu_sync", "ok", detail, start) else: mailu.sync(reason="ariadne_access_approve", force=True) mailbox_ready = mailu.wait_for_mailbox( mailu_email, settings.mailu_mailbox_wait_timeout_sec ) if not mailbox_ready: raise RuntimeError("mailbox not ready") self._upsert_task(conn, request_code, "mailu_sync", "ok", None) self._record_task(request_code, "mailu_sync", "ok", None, start) except Exception as exc: mailbox_ready = False detail = safe_error_detail(exc, "failed to sync mailu") self._upsert_task(conn, request_code, "mailu_sync", "error", detail) self._record_task(request_code, "mailu_sync", "error", detail, start) if not mailbox_ready: logger.info( "mailbox not ready after sync", extra={"event": "mailu_mailbox_wait", "request_code": request_code, "status": "retry"}, ) return ProvisionOutcome(ok=False, status="accounts_building") # Task: trigger Nextcloud mail sync start = datetime.now(timezone.utc) try: if not settings.nextcloud_namespace or not settings.nextcloud_mail_sync_cronjob: detail = "sync disabled" self._upsert_task(conn, request_code, "nextcloud_mail_sync", "ok", detail) self._record_task(request_code, "nextcloud_mail_sync", "ok", detail, start) else: result = nextcloud.sync_mail(username, wait=True) if isinstance(result, dict) and result.get("status") == "ok": self._upsert_task(conn, request_code, "nextcloud_mail_sync", "ok", None) self._record_task(request_code, "nextcloud_mail_sync", "ok", None, start) else: status_val = result.get("status") if isinstance(result, dict) else "error" detail = str(status_val) self._upsert_task(conn, request_code, "nextcloud_mail_sync", "error", detail) self._record_task(request_code, "nextcloud_mail_sync", "error", detail, start) except Exception as exc: detail = safe_error_detail(exc, "failed to sync nextcloud") self._upsert_task(conn, request_code, "nextcloud_mail_sync", "error", detail) self._record_task(request_code, "nextcloud_mail_sync", "error", detail, start) # Task: ensure wger account exists start = datetime.now(timezone.utc) try: full = keycloak_admin.get_user(user_id) attrs = full.get("attributes") or {} wger_password = _extract_attr(attrs, WGER_PASSWORD_ATTR) wger_password_updated_at = _extract_attr(attrs, WGER_PASSWORD_UPDATED_ATTR) if not wger_password: wger_password = random_password(20) keycloak_admin.set_user_attribute(username, WGER_PASSWORD_ATTR, wger_password) if not wger_password_updated_at: result = wger.sync_user(username, mailu_email, wger_password, wait=True) status_val = result.get("status") if isinstance(result, dict) else "error" if status_val != "ok": raise RuntimeError(f"wger sync {status_val}") now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") keycloak_admin.set_user_attribute(username, WGER_PASSWORD_UPDATED_ATTR, now_iso) self._upsert_task(conn, request_code, "wger_account", "ok", None) self._record_task(request_code, "wger_account", "ok", None, start) except Exception as exc: detail = safe_error_detail(exc, "failed to provision wger") self._upsert_task(conn, request_code, "wger_account", "error", detail) self._record_task(request_code, "wger_account", "error", detail, start) # Task: ensure firefly account exists start = datetime.now(timezone.utc) try: full = keycloak_admin.get_user(user_id) attrs = full.get("attributes") or {} firefly_password = _extract_attr(attrs, FIREFLY_PASSWORD_ATTR) firefly_password_updated_at = _extract_attr(attrs, FIREFLY_PASSWORD_UPDATED_ATTR) if not firefly_password: firefly_password = random_password(24) keycloak_admin.set_user_attribute(username, FIREFLY_PASSWORD_ATTR, firefly_password) if not firefly_password_updated_at: result = firefly.sync_user(mailu_email, firefly_password, wait=True) status_val = result.get("status") if isinstance(result, dict) else "error" if status_val != "ok": raise RuntimeError(f"firefly sync {status_val}") now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") keycloak_admin.set_user_attribute(username, FIREFLY_PASSWORD_UPDATED_ATTR, now_iso) self._upsert_task(conn, request_code, "firefly_account", "ok", None) self._record_task(request_code, "firefly_account", "ok", None, start) except Exception as exc: detail = safe_error_detail(exc, "failed to provision firefly") self._upsert_task(conn, request_code, "firefly_account", "error", detail) self._record_task(request_code, "firefly_account", "error", detail, start) # Task: ensure Vaultwarden account exists (invite flow) start = datetime.now(timezone.utc) try: if not mailu.wait_for_mailbox(mailu_email, settings.mailu_mailbox_wait_timeout_sec): try: mailu.sync(reason="ariadne_vaultwarden_retry", force=True) except Exception: pass if not mailu.wait_for_mailbox(mailu_email, settings.mailu_mailbox_wait_timeout_sec): raise RuntimeError("mailbox not ready") result = vaultwarden.invite_user(mailu_email) if result.ok: self._upsert_task(conn, request_code, "vaultwarden_invite", "ok", result.status) self._record_task(request_code, "vaultwarden_invite", "ok", result.status, start) else: detail = result.detail or result.status self._upsert_task(conn, request_code, "vaultwarden_invite", "error", detail) self._record_task(request_code, "vaultwarden_invite", "error", detail, start) try: now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") keycloak_admin.set_user_attribute(username, "vaultwarden_email", mailu_email) keycloak_admin.set_user_attribute(username, "vaultwarden_status", result.status) keycloak_admin.set_user_attribute(username, "vaultwarden_synced_at", now_iso) except Exception: pass except Exception as exc: detail = safe_error_detail(exc, "failed to provision vaultwarden") self._upsert_task(conn, request_code, "vaultwarden_invite", "error", detail) self._record_task(request_code, "vaultwarden_invite", "error", detail, start) if self._all_tasks_ok(conn, request_code, required_tasks): conn.execute( """ UPDATE access_requests SET status = 'awaiting_onboarding' WHERE request_code = %s AND status = 'accounts_building' """, (request_code,), ) self._send_welcome_email(request_code, username, contact_email) logger.info( "provisioning complete", extra={ "event": "provision_complete", "request_code": request_code, "username": username, "status": "awaiting_onboarding", }, ) return ProvisionOutcome(ok=True, status="awaiting_onboarding") logger.info( "provisioning pending", extra={"event": "provision_pending", "request_code": request_code, "status": "accounts_building"}, ) return ProvisionOutcome(ok=False, status="accounts_building") finally: conn.execute("SELECT pg_advisory_unlock(%s)", (lock_id,)) def _ensure_task_rows(self, conn, request_code: str, tasks: list[str]) -> None: if not tasks: return conn.execute( """ INSERT INTO access_request_tasks (request_code, task, status, detail, updated_at) SELECT %s, task, 'pending', NULL, NOW() FROM UNNEST(%s::text[]) AS task ON CONFLICT (request_code, task) DO NOTHING """, (request_code, tasks), ) def _upsert_task(self, conn, request_code: str, task: str, status: str, detail: str | None = None) -> None: conn.execute( """ INSERT INTO access_request_tasks (request_code, task, status, detail, updated_at) VALUES (%s, %s, %s, %s, NOW()) ON CONFLICT (request_code, task) DO UPDATE SET status = EXCLUDED.status, detail = EXCLUDED.detail, updated_at = NOW() """, (request_code, task, status, detail), ) def _task_statuses(self, conn, request_code: str) -> dict[str, str]: rows = conn.execute( "SELECT task, status FROM access_request_tasks WHERE request_code = %s", (request_code,), ).fetchall() output: dict[str, str] = {} for row in rows: task = row.get("task") if isinstance(row, dict) else None status = row.get("status") if isinstance(row, dict) else None if isinstance(task, str) and isinstance(status, str): output[task] = status return output def _all_tasks_ok(self, conn, request_code: str, tasks: list[str]) -> bool: statuses = self._task_statuses(conn, request_code) for task in tasks: if statuses.get(task) != "ok": return False return True def _record_task(self, request_code: str, task: str, status: str, detail: str | None, started: datetime) -> None: finished = datetime.now(timezone.utc) duration_sec = (finished - started).total_seconds() record_task_run(task, status, duration_sec) logger.info( "task run", extra={ "event": "task_run", "request_code": request_code, "task": task, "status": status, "duration_sec": round(duration_sec, 3), "detail": detail or "", }, ) try: self._storage.record_task_run( request_code, task, status, detail, started, finished, int(duration_sec * 1000), ) except Exception: pass def _send_welcome_email(self, request_code: str, username: str, contact_email: str) -> None: if not settings.welcome_email_enabled: return if not contact_email: return try: row = self._db.fetchone( "SELECT welcome_email_sent_at FROM access_requests WHERE request_code = %s", (request_code,), ) if row and row.get("welcome_email_sent_at"): return onboarding_url = f"{settings.portal_public_base_url}/onboarding?code={request_code}" mailer.send_welcome(contact_email, request_code, onboarding_url, username=username) self._storage.mark_welcome_sent(request_code) except MailerError: return