From d1cbec8993846283fa8c39b3880bf313492a81c1 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Wed, 21 Jan 2026 02:57:06 -0300 Subject: [PATCH] feat: absorb glue tasks and mailu events --- Jenkinsfile | 1 + ariadne/app.py | 382 ++++++----- ariadne/auth/keycloak.py | 43 +- ariadne/db/storage.py | 72 ++- ariadne/manager/provisioning.py | 904 +++++++++++++++------------ ariadne/metrics/metrics.py | 7 + ariadne/scheduler/cron.py | 36 +- ariadne/services/comms.py | 393 +++++++----- ariadne/services/firefly.py | 265 +++++++- ariadne/services/mailu.py | 346 +++++++++- ariadne/services/mailu_events.py | 151 +++++ ariadne/services/nextcloud.py | 465 ++++++++------ ariadne/services/opensearch_prune.py | 4 +- ariadne/services/vault.py | 173 +++-- ariadne/services/vaultwarden.py | 118 ++-- ariadne/services/vaultwarden_sync.py | 238 ++++--- ariadne/services/wger.py | 261 ++++++++ ariadne/settings.py | 460 ++++++++------ ariadne/utils/errors.py | 64 +- ariadne/utils/http.py | 5 +- ariadne/utils/logging.py | 2 +- ariadne/utils/name_generator.py | 25 + requirements-dev.txt | 1 + requirements.txt | 2 + tests/test_app.py | 17 + tests/test_mailu_events.py | 54 ++ tests/test_provisioning.py | 39 ++ tests/test_services.py | 286 +++++++-- tests/test_storage.py | 27 +- tests/test_utils.py | 7 + 30 files changed, 3364 insertions(+), 1484 deletions(-) create mode 100644 ariadne/services/mailu_events.py create mode 100644 ariadne/utils/name_generator.py create mode 100644 tests/test_mailu_events.py diff --git a/Jenkinsfile b/Jenkinsfile index b2e3dea..5ea7008 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -99,6 +99,7 @@ spec: set -euo pipefail python -m pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt mkdir -p build + python -m ruff check ariadne --select C90,PLR python -m slipcover \ --json \ --out "${COVERAGE_JSON}" \ diff --git a/ariadne/app.py b/ariadne/app.py index d1ceb14..eb06450 100644 --- a/ariadne/app.py +++ b/ariadne/app.py @@ -1,17 +1,18 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import datetime, timezone import json import threading -from typing import Any +from typing import Any, Callable -from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi import Body, Depends, FastAPI, HTTPException, Request from fastapi.responses import JSONResponse, Response from prometheus_client import CONTENT_TYPE_LATEST, generate_latest from .auth.keycloak import AuthContext, authenticator from .db.database import Database -from .db.storage import Storage +from .db.storage import Storage, TaskRunRecord from .manager.provisioning import ProvisioningManager from .metrics.metrics import record_task_run from .scheduler.cron import CronScheduler @@ -20,6 +21,7 @@ from .services.firefly import firefly 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.opensearch_prune import prune_indices @@ -37,6 +39,27 @@ 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 + db = Database(settings.portal_database_url) storage = Storage(db) provisioning = ProvisioningManager(db, storage) @@ -88,6 +111,121 @@ def _require_account_access(ctx: AuthContext) -> None: raise HTTPException(status_code=403, detail="forbidden") +async def _read_json_payload(request: Request) -> dict[str, Any]: + try: + payload = await request.json() + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + +def _note_from_payload(payload: dict[str, Any]) -> str | None: + note = payload.get("note") if isinstance(payload, dict) else None + return str(note).strip() if isinstance(note, str) and note.strip() else None + + +def _flags_from_payload(payload: dict[str, Any]) -> list[str]: + flags_raw = payload.get("flags") if isinstance(payload, dict) else None + return [flag for flag in flags_raw if isinstance(flag, str)] if isinstance(flags_raw, list) else [] + + +def _allowed_flag_groups() -> list[str]: + if not keycloak_admin.ready(): + return settings.allowed_flag_groups + try: + return keycloak_admin.list_group_names(exclude={"admin"}) + except Exception: + 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) + + @app.on_event("startup") def _startup() -> None: db.ensure_schema() @@ -115,7 +253,17 @@ def _startup() -> None: 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, @@ -176,7 +324,9 @@ def _startup() -> None: "nextcloud_cron": settings.nextcloud_cron, "nextcloud_maintenance_cron": settings.nextcloud_maintenance_cron, "vaultwarden_cron": settings.vaultwarden_sync_cron, + "wger_user_sync_cron": settings.wger_user_sync_cron, "wger_admin_cron": settings.wger_admin_cron, + "firefly_user_sync_cron": settings.firefly_user_sync_cron, "firefly_cron": settings.firefly_cron, "pod_cleaner_cron": settings.pod_cleaner_cron, "opensearch_prune_cron": settings.opensearch_prune_cron, @@ -317,22 +467,10 @@ async def approve_access_request( ) -> JSONResponse: _require_admin(ctx) with task_context("admin.access.approve"): - try: - payload = await request.json() - except Exception: - payload = {} - - flags_raw = payload.get("flags") if isinstance(payload, dict) else None - flags = [f for f in flags_raw if isinstance(f, str)] if isinstance(flags_raw, list) else [] - allowed_flags = settings.allowed_flag_groups - if keycloak_admin.ready(): - try: - allowed_flags = keycloak_admin.list_group_names(exclude={"admin"}) - except Exception: - allowed_flags = settings.allowed_flag_groups - flags = [f for f in flags if f in allowed_flags] - note = payload.get("note") if isinstance(payload, dict) else None - note = str(note).strip() if isinstance(note, str) else None + 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: @@ -407,12 +545,8 @@ async def deny_access_request( ) -> JSONResponse: _require_admin(ctx) with task_context("admin.access.deny"): - try: - payload = await request.json() - except Exception: - payload = {} - note = payload.get("note") if isinstance(payload, dict) else None - note = str(note).strip() if isinstance(note, str) else None + payload = await _read_json_payload(request) + note = _note_from_payload(payload) decided_by = ctx.username or "" try: @@ -480,7 +614,7 @@ def rotate_mailu_password(ctx: AuthContext = Depends(_require_auth)) -> JSONResp started = datetime.now(timezone.utc) status = "ok" error_detail = "" - sync_enabled = bool(settings.mailu_sync_url) + sync_enabled = mailu.ready() sync_ok = False sync_error = "" nextcloud_sync: dict[str, Any] = {"status": "skipped"} @@ -538,13 +672,15 @@ def rotate_mailu_password(ctx: AuthContext = Depends(_require_auth)) -> JSONResp record_task_run("mailu_rotate", status, duration_sec) try: storage.record_task_run( - None, - "mailu_rotate", - status, - error_detail or None, - started, - finished, - int(duration_sec * 1000), + 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 @@ -572,71 +708,20 @@ def reset_wger_password(ctx: AuthContext = Depends(_require_auth)) -> JSONRespon raise HTTPException(status_code=400, detail="missing username") with task_context("account.wger_reset"): - 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: - mailu_email = str(raw_mailu[0]) - elif isinstance(raw_mailu, str) and raw_mailu: - mailu_email = raw_mailu - except Exception: - pass - - started = datetime.now(timezone.utc) - status = "ok" - error_detail = "" - logger.info("wger password reset requested", extra={"event": "wger_reset", "username": username}) - try: - password = random_password() - result = wger.sync_user(username, mailu_email, 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}") - - keycloak_admin.set_user_attribute(username, "wger_password", password) - keycloak_admin.set_user_attribute( - username, - "wger_password_updated_at", - datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - ) - - logger.info("wger password reset completed", extra={"event": "wger_reset", "username": username}) - return JSONResponse({"status": "ok", "password": 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, "wger sync failed") - raise HTTPException(status_code=502, detail=error_detail) - finally: - finished = datetime.now(timezone.utc) - duration_sec = (finished - started).total_seconds() - record_task_run("wger_reset", status, duration_sec) - try: - storage.record_task_run( - None, - "wger_reset", - status, - error_detail or None, - started, - finished, - int(duration_sec * 1000), - ) - except Exception: - pass - _record_event( - "wger_reset", - { - "username": username, - "status": status, - "error": error_detail, - }, - ) + 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") @@ -650,71 +735,20 @@ def reset_firefly_password(ctx: AuthContext = Depends(_require_auth)) -> JSONRes raise HTTPException(status_code=400, detail="missing username") with task_context("account.firefly_reset"): - 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: - mailu_email = str(raw_mailu[0]) - elif isinstance(raw_mailu, str) and raw_mailu: - mailu_email = raw_mailu - except Exception: - pass - - started = datetime.now(timezone.utc) - status = "ok" - error_detail = "" - logger.info("firefly password reset requested", extra={"event": "firefly_reset", "username": username}) - try: - password = random_password(24) - result = firefly.sync_user(mailu_email, 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}") - - keycloak_admin.set_user_attribute(username, "firefly_password", password) - keycloak_admin.set_user_attribute( - username, - "firefly_password_updated_at", - datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - ) - - logger.info("firefly password reset completed", extra={"event": "firefly_reset", "username": username}) - return JSONResponse({"status": "ok", "password": 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, "firefly sync failed") - raise HTTPException(status_code=502, detail=error_detail) - finally: - finished = datetime.now(timezone.utc) - duration_sec = (finished - started).total_seconds() - record_task_run("firefly_reset", status, duration_sec) - try: - storage.record_task_run( - None, - "firefly_reset", - status, - error_detail or None, - started, - finished, - int(duration_sec * 1000), - ) - except Exception: - pass - _record_event( - "firefly_reset", - { - "username": username, - "status": status, - "error": error_detail, - }, - ) + 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/nextcloud/mail/sync") @@ -770,13 +804,15 @@ async def nextcloud_mail_sync(request: Request, ctx: AuthContext = Depends(_requ record_task_run("nextcloud_sync", status, duration_sec) try: storage.record_task_run( - None, - "nextcloud_sync", - status, - error_detail or None, - started, - finished, - int(duration_sec * 1000), + 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 @@ -789,3 +825,9 @@ async def nextcloud_mail_sync(request: Request, ctx: AuthContext = Depends(_requ "error": error_detail, }, ) + + +@app.post("/events") +def mailu_event_listener(payload: dict[str, Any] | None = Body(default=None)) -> Response: + status_code, response = mailu_events.handle_event(payload) + return JSONResponse(response, status_code=status_code) diff --git a/ariadne/auth/keycloak.py b/ariadne/auth/keycloak.py index 74ee6d2..eb1e3b4 100644 --- a/ariadne/auth/keycloak.py +++ b/ariadne/auth/keycloak.py @@ -27,30 +27,33 @@ class KeycloakOIDC: self._jwks_fetched_at: float = 0.0 self._jwks_ttl_sec = 300.0 - def verify(self, token: str) -> dict[str, Any]: - if not token: - raise ValueError("missing token") + def _get_kid(self, token: str) -> str: header = jwt.get_unverified_header(token) kid = header.get("kid") if not isinstance(kid, str): raise ValueError("token missing kid") - jwks = self._get_jwks() - key = None + return kid + + def _find_key(self, jwks: dict[str, Any], kid: str) -> dict[str, Any] | None: for candidate in jwks.get("keys", []) if isinstance(jwks, dict) else []: if isinstance(candidate, dict) and candidate.get("kid") == kid: - key = candidate - break - if not key: - self._jwks = None - jwks = self._get_jwks(force=True) - for candidate in jwks.get("keys", []) if isinstance(jwks, dict) else []: - if isinstance(candidate, dict) and candidate.get("kid") == kid: - key = candidate - break + return candidate + return None + + def _resolve_key(self, kid: str) -> dict[str, Any]: + jwks = self._get_jwks() + key = self._find_key(jwks, kid) + if key: + return key + self._jwks = None + jwks = self._get_jwks(force=True) + key = self._find_key(jwks, kid) if not key: raise ValueError("token kid not found") + return key - claims = jwt.decode( + def _decode_claims(self, token: str, key: dict[str, Any]) -> dict[str, Any]: + return jwt.decode( token, key=jwt.algorithms.RSAAlgorithm.from_jwk(key), algorithms=["RS256"], @@ -58,16 +61,24 @@ class KeycloakOIDC: issuer=self._issuer, ) + def _validate_audience(self, claims: dict[str, Any]) -> None: azp = claims.get("azp") aud = claims.get("aud") aud_list: list[str] = [] if isinstance(aud, str): aud_list = [aud] elif isinstance(aud, list): - aud_list = [a for a in aud if isinstance(a, str)] + aud_list = [item for item in aud if isinstance(item, str)] if azp != self._client_id and self._client_id not in aud_list: raise ValueError("token not issued for expected client") + def verify(self, token: str) -> dict[str, Any]: + if not token: + raise ValueError("missing token") + kid = self._get_kid(token) + key = self._resolve_key(kid) + claims = self._decode_claims(token, key) + self._validate_audience(claims) return claims def _get_jwks(self, force: bool = False) -> dict[str, Any]: diff --git a/ariadne/db/storage.py b/ariadne/db/storage.py index abeb4b6..00cac5a 100644 --- a/ariadne/db/storage.py +++ b/ariadne/db/storage.py @@ -36,6 +36,29 @@ class AccessRequest: denial_note: str | None +@dataclass(frozen=True) +class TaskRunRecord: + request_code: str | None + task: str + status: str + detail: str | None + started_at: datetime + finished_at: datetime | None + duration_ms: int | None + + +@dataclass(frozen=True) +class ScheduleState: + task_name: str + cron_expr: str + last_started_at: datetime | None + last_finished_at: datetime | None + last_status: str | None + last_error: str | None + last_duration_ms: int | None + next_run_at: datetime | None + + class Storage: def __init__(self, db: Database) -> None: self._db = db @@ -189,36 +212,25 @@ class Storage: (status, decided_by or None, flags or None, note, status, note, request_code), ) - def record_task_run( - self, - request_code: str | None, - task: str, - status: str, - detail: str | None, - started_at: datetime, - finished_at: datetime | None, - duration_ms: int | None, - ) -> None: + def record_task_run(self, record: TaskRunRecord) -> None: self._db.execute( """ INSERT INTO ariadne_task_runs (request_code, task, status, detail, started_at, finished_at, duration_ms) VALUES (%s, %s, %s, %s, %s, %s, %s) """, - (request_code, task, status, detail, started_at, finished_at, duration_ms), + ( + record.request_code, + record.task, + record.status, + record.detail, + record.started_at, + record.finished_at, + record.duration_ms, + ), ) - def update_schedule_state( - self, - task_name: str, - cron_expr: str, - last_started_at: datetime | None, - last_finished_at: datetime | None, - last_status: str | None, - last_error: str | None, - last_duration_ms: int | None, - next_run_at: datetime | None, - ) -> None: + def update_schedule_state(self, state: ScheduleState) -> None: self._db.execute( """ INSERT INTO ariadne_schedule_state @@ -236,14 +248,14 @@ class Storage: updated_at = NOW() """, ( - task_name, - cron_expr, - last_started_at, - last_finished_at, - last_status, - last_error, - last_duration_ms, - next_run_at, + state.task_name, + state.cron_expr, + state.last_started_at, + state.last_finished_at, + state.last_status, + state.last_error, + state.last_duration_ms, + state.next_run_at, ), ) diff --git a/ariadne/manager/provisioning.py b/ariadne/manager/provisioning.py index 3fc9bd0..8438126 100644 --- a/ariadne/manager/provisioning.py +++ b/ariadne/manager/provisioning.py @@ -8,7 +8,7 @@ import time from typing import Any from ..db.database import Database -from ..db.storage import REQUIRED_TASKS, Storage +from ..db.storage import REQUIRED_TASKS, Storage, TaskRunRecord from ..metrics.metrics import record_task_run, set_access_request_counts from ..services.firefly import firefly from ..services.keycloak_admin import keycloak_admin @@ -40,6 +40,21 @@ class ProvisionOutcome: status: str +@dataclass +class RequestContext: + request_code: str + username: str + contact_email: str + email_verified_at: datetime | None + status: str + initial_password: str | None + revealed_at: datetime | None + attempted_at: datetime | None + approval_flags: list[str] + user_id: str = "" + mailu_email: 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) @@ -104,6 +119,39 @@ class ProvisioningManager: payload[status] = count set_access_request_counts(payload) + def _load_request(self, conn, request_code: str) -> RequestContext | None: + 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 None + + username = str(row.get("username") or "") + return RequestContext( + request_code=request_code, + username=username, + 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 [], + mailu_email=f"{username}@{settings.mailu_domain}", + ) + def provision_access_request(self, request_code: str) -> ProvisionOutcome: if not request_code: return ProvisionOutcome(ok=False, status="unknown") @@ -111,6 +159,55 @@ class ProvisioningManager: return ProvisionOutcome(ok=False, status="accounts_building") required_tasks = list(REQUIRED_TASKS) + self._log_provision_start(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: + return self._provision_locked(conn, request_code, required_tasks) + finally: + conn.execute("SELECT pg_advisory_unlock(%s)", (lock_id,)) + + def _provision_locked( + self, + conn, + request_code: str, + required_tasks: list[str], + ) -> ProvisionOutcome: + ctx = self._load_request(conn, request_code) + if not ctx: + return ProvisionOutcome(ok=False, status="unknown") + + self._transition_to_building(conn, ctx) + if not self._status_allowed(ctx): + return ProvisionOutcome(ok=False, status=ctx.status or "unknown") + + self._ensure_task_rows(conn, ctx.request_code, required_tasks) + if not self._mark_attempt(conn, ctx): + return ProvisionOutcome(ok=False, status="accounts_building") + + return self._run_task_pipeline(conn, ctx, required_tasks) + + def _run_task_pipeline( + self, + conn, + ctx: RequestContext, + required_tasks: list[str], + ) -> ProvisionOutcome: + if not self._ensure_keycloak_user(conn, ctx): + return ProvisionOutcome(ok=False, status="accounts_building") + if not self._run_account_tasks(conn, ctx): + return ProvisionOutcome(ok=False, status="accounts_building") + if self._all_tasks_ok(conn, ctx.request_code, required_tasks): + return self._complete_provisioning(conn, ctx) + + pending_status = "accounts_building" if ctx.status == "accounts_building" else ctx.status + return self._pending_provisioning(ctx, pending_status) + + def _log_provision_start(self, request_code: str) -> None: logger.info( "provisioning started", extra={"event": "provision_start", "request_code": request_code}, @@ -123,400 +220,102 @@ class ProvisioningManager: except Exception: pass - 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") + def _transition_to_building(self, conn, ctx: RequestContext) -> None: + if ctx.status != "approved": + return + conn.execute( + """ + UPDATE access_requests + SET status = 'accounts_building' + WHERE request_code = %s AND status = 'approved' + """, + (ctx.request_code,), + ) + ctx.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") + def _status_allowed(self, ctx: RequestContext) -> bool: + return ctx.status in {"accounts_building", "awaiting_onboarding", "ready"} - 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 [] + def _mark_attempt(self, conn, ctx: RequestContext) -> bool: + if ctx.status != "accounts_building": + return True + if not self._ready_for_retry(ctx): + return False + conn.execute( + "UPDATE access_requests SET provision_attempted_at = NOW() WHERE request_code = %s", + (ctx.request_code,), + ) + return True - if status == "approved": - conn.execute( - """ - UPDATE access_requests - SET status = 'accounts_building' - WHERE request_code = %s AND status = 'approved' - """, - (request_code,), - ) - status = "accounts_building" + def _run_account_tasks(self, conn, ctx: RequestContext) -> bool: + self._ensure_keycloak_password(conn, ctx) + self._ensure_keycloak_groups(conn, ctx) + self._ensure_mailu_app_password(conn, ctx) + if not self._sync_mailu(conn, ctx): + logger.info( + "mailbox not ready after sync", + extra={"event": "mailu_mailbox_wait", "request_code": ctx.request_code, "status": "retry"}, + ) + return False - if status not in {"accounts_building", "awaiting_onboarding", "ready"}: - return ProvisionOutcome(ok=False, status=status or "unknown") + self._sync_nextcloud_mail(conn, ctx) + self._ensure_wger_account(conn, ctx) + self._ensure_firefly_account(conn, ctx) + self._ensure_vaultwarden_invite(conn, ctx) + return True - self._ensure_task_rows(conn, request_code, required_tasks) + def _complete_provisioning(self, conn, ctx: RequestContext) -> ProvisionOutcome: + conn.execute( + """ + UPDATE access_requests + SET status = 'awaiting_onboarding' + WHERE request_code = %s AND status = 'accounts_building' + """, + (ctx.request_code,), + ) + self._send_welcome_email(ctx.request_code, ctx.username, ctx.contact_email) + logger.info( + "provisioning complete", + extra={ + "event": "provision_complete", + "request_code": ctx.request_code, + "username": ctx.username, + "status": "awaiting_onboarding", + }, + ) + try: + self._storage.record_event( + "provision_complete", + { + "request_code": ctx.request_code, + "username": ctx.username, + "status": "awaiting_onboarding", + }, + ) + except Exception: + pass + return ProvisionOutcome(ok=True, status="awaiting_onboarding") - 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}) - email_value = full.get("email") - if ( - (not isinstance(email_value, str) or not email_value.strip()) - and isinstance(email_verified_at, datetime) - and contact_email.strip() - ): - keycloak_admin.update_user_safe( - user_id, - {"email": contact_email.strip(), "emailVerified": True}, - ) - 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: - 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", - }, - ) - try: - self._storage.record_event( - "provision_complete", - { - "request_code": request_code, - "username": username, - "status": "awaiting_onboarding", - }, - ) - except Exception: - pass - return ProvisionOutcome(ok=True, status="awaiting_onboarding") - - pending_status = "accounts_building" if status == "accounts_building" else status - logger.info( - "provisioning pending", - extra={"event": "provision_pending", "request_code": request_code, "status": pending_status}, - ) - try: - self._storage.record_event( - "provision_pending", - { - "request_code": request_code, - "status": pending_status, - }, - ) - except Exception: - pass - return ProvisionOutcome(ok=False, status=pending_status) - finally: - conn.execute("SELECT pg_advisory_unlock(%s)", (lock_id,)) + def _pending_provisioning(self, ctx: RequestContext, pending_status: str) -> ProvisionOutcome: + logger.info( + "provisioning pending", + extra={ + "event": "provision_pending", + "request_code": ctx.request_code, + "status": pending_status, + }, + ) + try: + self._storage.record_event( + "provision_pending", + { + "request_code": ctx.request_code, + "status": pending_status, + }, + ) + except Exception: + pass + return ProvisionOutcome(ok=False, status=pending_status) def _ensure_task_rows(self, conn, request_code: str, tasks: list[str]) -> None: if not tasks: @@ -592,17 +391,330 @@ class ProvisioningManager: pass try: self._storage.record_task_run( - request_code, - task, - status, - detail, - started, - finished, - int(duration_sec * 1000), + TaskRunRecord( + request_code=request_code, + task=task, + status=status, + detail=detail, + started_at=started, + finished_at=finished, + duration_ms=int(duration_sec * 1000), + ) ) except Exception: pass + def _task_ok( + self, + conn, + request_code: str, + task: str, + detail: str | None, + started: datetime, + ) -> None: + self._upsert_task(conn, request_code, task, "ok", detail) + self._record_task(request_code, task, "ok", detail, started) + + def _task_error( + self, + conn, + request_code: str, + task: str, + detail: str, + started: datetime, + ) -> None: + self._upsert_task(conn, request_code, task, "error", detail) + self._record_task(request_code, task, "error", detail, started) + + def _ready_for_retry(self, ctx: RequestContext) -> bool: + if ctx.status != "accounts_building": + return True + attempted_at = ctx.attempted_at + if not isinstance(attempted_at, datetime): + return True + if attempted_at.tzinfo is None: + attempted_at = attempted_at.replace(tzinfo=timezone.utc) + age_sec = (datetime.now(timezone.utc) - attempted_at).total_seconds() + return age_sec >= settings.provision_retry_cooldown_sec + + def _require_verified_email(self, ctx: RequestContext) -> str: + if not isinstance(ctx.email_verified_at, datetime): + raise RuntimeError("missing verified email address") + email = ctx.contact_email.strip() + if not email: + raise RuntimeError("missing verified email address") + return email + + def _ensure_email_unused(self, email: str, username: str) -> None: + 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") + + def _new_user_payload(self, username: str, email: str, mailu_email: str) -> dict[str, Any]: + return { + "username": username, + "enabled": True, + "email": email, + "emailVerified": True, + "requiredActions": [], + "attributes": { + MAILU_EMAIL_ATTR: [mailu_email], + MAILU_ENABLED_ATTR: ["true"], + }, + } + + def _create_or_fetch_user(self, ctx: RequestContext) -> dict[str, Any]: + user = keycloak_admin.find_user(ctx.username) + if user: + return user + email = self._require_verified_email(ctx) + self._ensure_email_unused(email, ctx.username) + payload = self._new_user_payload(ctx.username, email, ctx.mailu_email) + created_id = keycloak_admin.create_user(payload) + return keycloak_admin.get_user(created_id) + + def _fetch_full_user(self, user_id: str, fallback: dict[str, Any]) -> dict[str, Any]: + try: + return keycloak_admin.get_user(user_id) + except Exception: + return fallback + + def _strip_totp_action(self, user_id: str, full_user: dict[str, Any]) -> None: + actions = full_user.get("requiredActions") + if not isinstance(actions, list) or "CONFIGURE_TOTP" not in actions: + return + new_actions = [action for action in actions if action != "CONFIGURE_TOTP"] + keycloak_admin.update_user_safe(user_id, {"requiredActions": new_actions}) + + def _ensure_contact_email(self, ctx: RequestContext, full_user: dict[str, Any]) -> None: + email_value = full_user.get("email") + if isinstance(email_value, str) and email_value.strip(): + return + if isinstance(ctx.email_verified_at, datetime) and ctx.contact_email.strip(): + keycloak_admin.update_user_safe( + ctx.user_id, + {"email": ctx.contact_email.strip(), "emailVerified": True}, + ) + + def _ensure_mailu_attrs(self, ctx: RequestContext, full_user: dict[str, Any]) -> None: + attrs = full_user.get("attributes") or {} + if not isinstance(attrs, dict): + return + existing = _extract_attr(attrs, MAILU_EMAIL_ATTR) + if existing: + ctx.mailu_email = existing + else: + ctx.mailu_email = f"{ctx.username}@{settings.mailu_domain}" + keycloak_admin.set_user_attribute(ctx.username, MAILU_EMAIL_ATTR, ctx.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(ctx.username, MAILU_ENABLED_ATTR, "true") + + def _sync_user_profile(self, ctx: RequestContext, user: dict[str, Any]) -> None: + try: + full_user = self._fetch_full_user(ctx.user_id, user) + self._strip_totp_action(ctx.user_id, full_user) + self._ensure_contact_email(ctx, full_user) + self._ensure_mailu_attrs(ctx, full_user) + except Exception: + ctx.mailu_email = f"{ctx.username}@{settings.mailu_domain}" + + def _ensure_keycloak_user(self, conn, ctx: RequestContext) -> bool: + start = datetime.now(timezone.utc) + try: + user = self._create_or_fetch_user(ctx) + ctx.user_id = str((user or {}).get("id") or "") + if not ctx.user_id: + raise RuntimeError("user id missing") + self._sync_user_profile(ctx, user) + self._task_ok(conn, ctx.request_code, "keycloak_user", None, start) + return True + except Exception as exc: + detail = safe_error_detail(exc, "failed to ensure user") + self._task_error(conn, ctx.request_code, "keycloak_user", detail, start) + return False + + def _ensure_keycloak_password(self, conn, ctx: RequestContext) -> None: + start = datetime.now(timezone.utc) + try: + should_reset = ctx.status == "accounts_building" and ctx.revealed_at is None + password_value: str | None = None + + if should_reset: + if isinstance(ctx.initial_password, str) and ctx.initial_password: + password_value = ctx.initial_password + elif ctx.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, ctx.request_code), + ) + ctx.initial_password = password_value + + if password_value: + keycloak_admin.reset_password(ctx.user_id, password_value, temporary=False) + + if isinstance(ctx.initial_password, str) and ctx.initial_password: + self._task_ok(conn, ctx.request_code, "keycloak_password", None, start) + elif ctx.revealed_at is not None: + detail = "initial password already revealed" + self._task_ok(conn, ctx.request_code, "keycloak_password", detail, start) + else: + raise RuntimeError("initial password missing") + except Exception as exc: + detail = safe_error_detail(exc, "failed to set password") + self._task_error(conn, ctx.request_code, "keycloak_password", detail, start) + + def _ensure_keycloak_groups(self, conn, ctx: RequestContext) -> None: + start = datetime.now(timezone.utc) + try: + approved_flags = [flag for flag in ctx.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(ctx.user_id, gid) + self._task_ok(conn, ctx.request_code, "keycloak_groups", None, start) + except Exception as exc: + detail = safe_error_detail(exc, "failed to add groups") + self._task_error(conn, ctx.request_code, "keycloak_groups", detail, start) + + def _ensure_mailu_app_password(self, conn, ctx: RequestContext) -> None: + start = datetime.now(timezone.utc) + try: + full = keycloak_admin.get_user(ctx.user_id) + attrs = full.get("attributes") or {} + existing = _extract_attr(attrs, MAILU_APP_PASSWORD_ATTR) + if not existing: + keycloak_admin.set_user_attribute(ctx.username, MAILU_APP_PASSWORD_ATTR, random_password()) + self._task_ok(conn, ctx.request_code, "mailu_app_password", None, start) + except Exception as exc: + detail = safe_error_detail(exc, "failed to set mail password") + self._task_error(conn, ctx.request_code, "mailu_app_password", detail, start) + + def _sync_mailu(self, conn, ctx: RequestContext) -> bool: + start = datetime.now(timezone.utc) + try: + if not mailu.ready(): + detail = "mailu not configured" + self._task_ok(conn, ctx.request_code, "mailu_sync", detail, start) + return True + mailu.sync(reason="ariadne_access_approve", force=True) + mailbox_ready = mailu.wait_for_mailbox( + ctx.mailu_email, + settings.mailu_mailbox_wait_timeout_sec, + ) + if not mailbox_ready: + raise RuntimeError("mailbox not ready") + self._task_ok(conn, ctx.request_code, "mailu_sync", None, start) + return True + except Exception as exc: + detail = safe_error_detail(exc, "failed to sync mailu") + self._task_error(conn, ctx.request_code, "mailu_sync", detail, start) + return False + + def _sync_nextcloud_mail(self, conn, ctx: RequestContext) -> None: + start = datetime.now(timezone.utc) + try: + if not settings.nextcloud_namespace: + detail = "sync disabled" + self._task_ok(conn, ctx.request_code, "nextcloud_mail_sync", detail, start) + return + result = nextcloud.sync_mail(ctx.username, wait=True) + if isinstance(result, dict) and result.get("status") == "ok": + self._task_ok(conn, ctx.request_code, "nextcloud_mail_sync", None, start) + return + status_val = result.get("status") if isinstance(result, dict) else "error" + detail = str(status_val) + self._task_error(conn, ctx.request_code, "nextcloud_mail_sync", detail, start) + except Exception as exc: + detail = safe_error_detail(exc, "failed to sync nextcloud") + self._task_error(conn, ctx.request_code, "nextcloud_mail_sync", detail, start) + + def _ensure_wger_account(self, conn, ctx: RequestContext) -> None: + start = datetime.now(timezone.utc) + try: + full = keycloak_admin.get_user(ctx.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(ctx.username, WGER_PASSWORD_ATTR, wger_password) + + if not wger_password_updated_at: + result = wger.sync_user(ctx.username, ctx.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(ctx.username, WGER_PASSWORD_UPDATED_ATTR, now_iso) + + self._task_ok(conn, ctx.request_code, "wger_account", None, start) + except Exception as exc: + detail = safe_error_detail(exc, "failed to provision wger") + self._task_error(conn, ctx.request_code, "wger_account", detail, start) + + def _ensure_firefly_account(self, conn, ctx: RequestContext) -> None: + start = datetime.now(timezone.utc) + try: + full = keycloak_admin.get_user(ctx.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(ctx.username, FIREFLY_PASSWORD_ATTR, firefly_password) + + if not firefly_password_updated_at: + result = firefly.sync_user(ctx.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(ctx.username, FIREFLY_PASSWORD_UPDATED_ATTR, now_iso) + + self._task_ok(conn, ctx.request_code, "firefly_account", None, start) + except Exception as exc: + detail = safe_error_detail(exc, "failed to provision firefly") + self._task_error(conn, ctx.request_code, "firefly_account", detail, start) + + def _ensure_vaultwarden_invite(self, conn, ctx: RequestContext) -> None: + start = datetime.now(timezone.utc) + try: + if not mailu.wait_for_mailbox(ctx.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(ctx.mailu_email, settings.mailu_mailbox_wait_timeout_sec): + raise RuntimeError("mailbox not ready") + + result = vaultwarden.invite_user(ctx.mailu_email) + if result.ok: + self._task_ok(conn, ctx.request_code, "vaultwarden_invite", result.status, start) + else: + detail = result.detail or result.status + self._task_error(conn, ctx.request_code, "vaultwarden_invite", detail, start) + + try: + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + keycloak_admin.set_user_attribute(ctx.username, "vaultwarden_email", ctx.mailu_email) + keycloak_admin.set_user_attribute(ctx.username, "vaultwarden_status", result.status) + keycloak_admin.set_user_attribute(ctx.username, "vaultwarden_synced_at", now_iso) + except Exception: + pass + except Exception as exc: + detail = safe_error_detail(exc, "failed to provision vaultwarden") + self._task_error(conn, ctx.request_code, "vaultwarden_invite", detail, start) + def _send_welcome_email(self, request_code: str, username: str, contact_email: str) -> None: if not settings.welcome_email_enabled: return diff --git a/ariadne/metrics/metrics.py b/ariadne/metrics/metrics.py index 789c433..e31c893 100644 --- a/ariadne/metrics/metrics.py +++ b/ariadne/metrics/metrics.py @@ -25,6 +25,11 @@ SCHEDULE_LAST_SUCCESS_TS = Gauge( "Last successful schedule run timestamp", ["task"], ) +SCHEDULE_LAST_ERROR_TS = Gauge( + "ariadne_schedule_last_error_timestamp_seconds", + "Last failed schedule run timestamp", + ["task"], +) SCHEDULE_NEXT_RUN_TS = Gauge( "ariadne_schedule_next_run_timestamp_seconds", "Next scheduled run timestamp", @@ -64,6 +69,8 @@ def record_schedule_state( SCHEDULE_NEXT_RUN_TS.labels(task=task).set(next_run_ts) if ok is not None: SCHEDULE_STATUS.labels(task=task).set(1 if ok else 0) + if ok is False and last_run_ts: + SCHEDULE_LAST_ERROR_TS.labels(task=task).set(last_run_ts) def set_access_request_counts(counts: dict[str, int]) -> None: diff --git a/ariadne/scheduler/cron.py b/ariadne/scheduler/cron.py index 963a27d..fcbdc61 100644 --- a/ariadne/scheduler/cron.py +++ b/ariadne/scheduler/cron.py @@ -9,7 +9,7 @@ from typing import Any, Callable from croniter import croniter -from ..db.storage import Storage +from ..db.storage import ScheduleState, Storage, TaskRunRecord from ..metrics.metrics import record_schedule_state, record_task_run from ..utils.logging import get_logger, task_context @@ -142,23 +142,27 @@ class CronScheduler: ) try: self._storage.record_task_run( - None, - task.name, - status, - detail_value or None, - started, - finished, - int(duration_sec * 1000), + TaskRunRecord( + request_code=None, + task=task.name, + status=status, + detail=detail_value or None, + started_at=started, + finished_at=finished, + duration_ms=int(duration_sec * 1000), + ) ) self._storage.update_schedule_state( - task.name, - task.cron_expr, - started, - finished, - status, - detail, - int(duration_sec * 1000), - self._next_run.get(task.name), + ScheduleState( + task_name=task.name, + cron_expr=task.cron_expr, + last_started_at=started, + last_finished_at=finished, + last_status=status, + last_error=detail, + last_duration_ms=int(duration_sec * 1000), + next_run_at=self._next_run.get(task.name), + ) ) except Exception: pass diff --git a/ariadne/services/comms.py b/ariadne/services/comms.py index c7c59fb..9608507 100644 --- a/ariadne/services/comms.py +++ b/ariadne/services/comms.py @@ -2,7 +2,6 @@ from __future__ import annotations from dataclasses import dataclass import base64 -import random import time import urllib.parse from typing import Any @@ -12,77 +11,18 @@ import psycopg from ..settings import settings from ..utils.logging import get_logger +from ..utils.name_generator import NameGenerator logger = get_logger(__name__) - -_ADJ = [ - "brisk", - "calm", - "eager", - "gentle", - "merry", - "nifty", - "rapid", - "sunny", - "witty", - "zesty", - "amber", - "bold", - "bright", - "crisp", - "daring", - "frosty", - "glad", - "jolly", - "lively", - "mellow", - "quiet", - "ripe", - "serene", - "spry", - "tidy", - "vivid", - "warm", - "wild", - "clever", - "kind", -] - -_NOUN = [ - "otter", - "falcon", - "comet", - "ember", - "grove", - "harbor", - "meadow", - "raven", - "river", - "summit", - "breeze", - "cedar", - "cinder", - "cove", - "delta", - "forest", - "glade", - "lark", - "marsh", - "peak", - "pine", - "quartz", - "reef", - "ridge", - "sable", - "sage", - "shore", - "thunder", - "vale", - "zephyr", -] - +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_ACCEPTED = 202 +HTTP_NO_CONTENT = 204 +HTTP_BAD_REQUEST = 400 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 @dataclass(frozen=True) class CommsSummary: @@ -93,6 +33,34 @@ class CommsSummary: detail: str = "" +@dataclass(frozen=True) +class MasGuestResult: + renamed: int + skipped: int + usernames: set[str] + + +@dataclass(frozen=True) +class SynapseGuestResult: + renamed: int + pruned: int + + +@dataclass(frozen=True) +class DisplayNameTarget: + room_id: str + user_id: str + name: str + in_room: bool + + +@dataclass(frozen=True) +class SynapseUserRef: + entry: dict[str, Any] + user_id: str + localpart: str + + def _auth(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} @@ -117,18 +85,19 @@ def _needs_rename_display(display: str | None) -> bool: return display.isdigit() or display.startswith("guest-") -def _random_name(existing: set[str]) -> str | None: - for _ in range(30): - candidate = f"{random.choice(_ADJ)}-{random.choice(_NOUN)}" - if candidate not in existing: - existing.add(candidate) - return candidate - return None class CommsService: - def __init__(self, client_factory: type[httpx.Client] = httpx.Client) -> None: + def __init__( + self, + client_factory: type[httpx.Client] = httpx.Client, + name_generator: NameGenerator | None = None, + ) -> None: self._client_factory = client_factory + self._name_generator = name_generator or NameGenerator() + + def _pick_guest_name(self, existing: set[str]) -> str | None: + return self._name_generator.unique(existing) def _client(self) -> httpx.Client: return self._client_factory(timeout=settings.comms_timeout_sec) @@ -290,7 +259,7 @@ class CommsService: extra={"event": "comms_guest_prune", "status": "error", "detail": str(exc)}, ) return False - if resp.status_code in (200, 202, 204, 404): + if resp.status_code in (HTTP_OK, HTTP_ACCEPTED, HTTP_NO_CONTENT, HTTP_NOT_FOUND): return True logger.info( "guest prune failed", @@ -315,7 +284,7 @@ class CommsService: f"{settings.comms_synapse_base}/_synapse/admin/v2/users/{urllib.parse.quote(user_id)}", headers=_auth(token), ) - if resp.status_code == 404: + if resp.status_code == HTTP_NOT_FOUND: return None resp.raise_for_status() return resp.json().get("displayname") @@ -324,27 +293,24 @@ class CommsService: self, client: httpx.Client, token: str, - room_id: str, - user_id: str, - name: str, - in_room: bool, + target: DisplayNameTarget, ) -> None: resp = client.put( - f"{settings.comms_synapse_base}/_matrix/client/v3/profile/{urllib.parse.quote(user_id)}/displayname", + f"{settings.comms_synapse_base}/_matrix/client/v3/profile/{urllib.parse.quote(target.user_id)}/displayname", headers=_auth(token), - json={"displayname": name}, + json={"displayname": target.name}, ) resp.raise_for_status() - if not in_room: + if not target.in_room: return state_url = ( - f"{settings.comms_synapse_base}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}" - f"/state/m.room.member/{urllib.parse.quote(user_id)}" + f"{settings.comms_synapse_base}/_matrix/client/v3/rooms/{urllib.parse.quote(target.room_id)}" + f"/state/m.room.member/{urllib.parse.quote(target.user_id)}" ) client.put( state_url, headers=_auth(token), - json={"membership": "join", "displayname": name}, + json={"membership": "join", "displayname": target.name}, ) def _set_displayname_admin(self, client: httpx.Client, token: str, user_id: str, name: str) -> bool: @@ -353,7 +319,7 @@ class CommsService: headers=_auth(token), json={"displayname": name}, ) - return resp.status_code in (200, 201, 204) + return resp.status_code in (HTTP_OK, HTTP_CREATED, HTTP_NO_CONTENT) def _db_rename_numeric(self, existing: set[str]) -> int: if not settings.comms_synapse_db_password: @@ -379,7 +345,7 @@ class CommsService: for _user_id, full_user_id, display in profile_rows: if display and not _needs_rename_display(display): continue - new_name = _random_name(existing) + new_name = self._pick_guest_name(existing) if not new_name: continue cur.execute( @@ -406,7 +372,7 @@ class CommsService: if full_user_id in profile_index: continue localpart = full_user_id.split(":", 1)[0].lstrip("@") - new_name = _random_name(existing) + new_name = self._pick_guest_name(existing) if not new_name: continue cur.execute( @@ -419,86 +385,179 @@ class CommsService: conn.close() return renamed - def run_guest_name_randomizer(self, wait: bool = True) -> dict[str, Any]: + def _validate_guest_name_settings(self) -> None: if not settings.comms_mas_admin_client_id or not settings.comms_mas_admin_client_secret: raise RuntimeError("comms mas admin secret missing") if not settings.comms_synapse_base: raise RuntimeError("comms synapse base missing") - processed = renamed = pruned = skipped = 0 + def _room_context(self, client: httpx.Client, token: str) -> tuple[str, set[str], set[str]]: + room_id = self._resolve_alias(client, token, settings.comms_room_alias) + members, existing = self._room_members(client, token, room_id) + return room_id, members, existing + + def _rename_mas_guests( + self, + client: httpx.Client, + admin_token: str, + room_id: str, + members: set[str], + existing: set[str], + ) -> MasGuestResult: + renamed = 0 + skipped = 0 + mas_usernames: set[str] = set() + users = self._mas_list_users(client, admin_token) + for user in users: + attrs = user.get("attributes") or {} + username = attrs.get("username") or "" + if isinstance(username, str) and username: + mas_usernames.add(username) + legacy_guest = attrs.get("legacy_guest") + if not isinstance(username, str) or not username: + skipped += 1 + continue + if not (legacy_guest or _needs_rename_username(username)): + skipped += 1 + continue + user_id = user.get("id") + if not isinstance(user_id, str) or not user_id: + skipped += 1 + continue + full_user = f"@{username}:{settings.comms_server_name}" + access_token, session_id = self._mas_personal_session(client, admin_token, user_id) + try: + display = self._get_displayname(client, access_token, full_user) + if display and not _needs_rename_display(display): + skipped += 1 + continue + new_name = self._pick_guest_name(existing) + if not new_name: + skipped += 1 + continue + self._set_displayname( + client, + access_token, + DisplayNameTarget( + room_id=room_id, + user_id=full_user, + name=new_name, + in_room=full_user in members, + ), + ) + renamed += 1 + finally: + self._mas_revoke_session(client, admin_token, session_id) + return MasGuestResult(renamed=renamed, skipped=skipped, usernames=mas_usernames) + + def _synapse_entries(self, client: httpx.Client, token: str) -> list[dict[str, Any]]: + try: + return self._synapse_list_users(client, token) + except Exception as exc: # noqa: BLE001 + logger.info( + "synapse admin list skipped", + extra={"event": "comms_guest_list", "status": "error", "detail": str(exc)}, + ) + return [] + + def _synapse_user_id(self, entry: dict[str, Any]) -> SynapseUserRef | None: + user_id = entry.get("name") or "" + if not isinstance(user_id, str) or not user_id.startswith("@"): + return None + localpart = user_id.split(":", 1)[0].lstrip("@") + return SynapseUserRef(entry=entry, user_id=user_id, localpart=localpart) + + def _maybe_prune_synapse_guest( + self, + client: httpx.Client, + token: str, + entry: dict[str, Any], + user_id: str, + now_ms: int, + ) -> bool: + if not entry.get("is_guest"): + return False + if not self._should_prune_guest(entry, now_ms): + return False + return self._prune_guest(client, token, user_id) + + def _needs_synapse_rename( + self, + client: httpx.Client, + token: str, + user: SynapseUserRef, + mas_usernames: set[str], + ) -> bool: + if user.localpart in mas_usernames: + return False + is_guest = user.entry.get("is_guest") + if not (is_guest or _needs_rename_username(user.localpart)): + return False + display = self._get_displayname_admin(client, token, user.user_id) + if display and not _needs_rename_display(display): + return False + return True + + def _rename_synapse_user( + self, + client: httpx.Client, + token: str, + existing: set[str], + user_id: str, + ) -> bool: + new_name = self._pick_guest_name(existing) + if not new_name: + return False + return self._set_displayname_admin(client, token, user_id, new_name) + + def _rename_synapse_guests( + self, + client: httpx.Client, + token: str, + existing: set[str], + mas_usernames: set[str], + ) -> SynapseGuestResult: + renamed = 0 + pruned = 0 + entries = self._synapse_entries(client, token) + + now_ms = int(time.time() * 1000) + for entry in entries: + user_ref = self._synapse_user_id(entry) + if not user_ref: + continue + if self._maybe_prune_synapse_guest(client, token, user_ref.entry, user_ref.user_id, now_ms): + pruned += 1 + continue + if not self._needs_synapse_rename(client, token, user_ref, mas_usernames): + continue + if self._rename_synapse_user(client, token, existing, user_ref.user_id): + renamed += 1 + return SynapseGuestResult(renamed=renamed, pruned=pruned) + + def run_guest_name_randomizer(self, wait: bool = True) -> dict[str, Any]: + self._validate_guest_name_settings() + with self._client() as client: admin_token = self._mas_admin_token(client) seeder_id = self._mas_user_id(client, admin_token, settings.comms_seeder_user) seeder_token, seeder_session = self._mas_personal_session(client, admin_token, seeder_id) try: - room_id = self._resolve_alias(client, seeder_token, settings.comms_room_alias) - members, existing = self._room_members(client, seeder_token, room_id) - users = self._mas_list_users(client, admin_token) - mas_usernames: set[str] = set() - for user in users: - attrs = user.get("attributes") or {} - username = attrs.get("username") or "" - if isinstance(username, str) and username: - mas_usernames.add(username) - legacy_guest = attrs.get("legacy_guest") - if not isinstance(username, str) or not username: - skipped += 1 - continue - if not (legacy_guest or _needs_rename_username(username)): - skipped += 1 - continue - user_id = f"@{username}:{settings.comms_server_name}" - access_token, session_id = self._mas_personal_session(client, admin_token, user["id"]) - try: - display = self._get_displayname(client, access_token, user_id) - if display and not _needs_rename_display(display): - skipped += 1 - continue - new_name = _random_name(existing) - if not new_name: - skipped += 1 - continue - self._set_displayname(client, access_token, room_id, user_id, new_name, user_id in members) - renamed += 1 - finally: - self._mas_revoke_session(client, admin_token, session_id) - - try: - entries = self._synapse_list_users(client, seeder_token) - except Exception as exc: # noqa: BLE001 - logger.info( - "synapse admin list skipped", - extra={"event": "comms_guest_list", "status": "error", "detail": str(exc)}, - ) - entries = [] - - now_ms = int(time.time() * 1000) - for entry in entries: - user_id = entry.get("name") or "" - if not user_id.startswith("@"): - continue - localpart = user_id.split(":", 1)[0].lstrip("@") - if localpart in mas_usernames: - continue - is_guest = entry.get("is_guest") - if is_guest and self._should_prune_guest(entry, now_ms): - if self._prune_guest(client, seeder_token, user_id): - pruned += 1 - continue - if not (is_guest or _needs_rename_username(localpart)): - continue - display = self._get_displayname_admin(client, seeder_token, user_id) - if display and not _needs_rename_display(display): - continue - new_name = _random_name(existing) - if not new_name: - continue - if self._set_displayname_admin(client, seeder_token, user_id, new_name): - renamed += 1 - renamed += self._db_rename_numeric(existing) + room_id, members, existing = self._room_context(client, seeder_token) + mas_result = self._rename_mas_guests(client, admin_token, room_id, members, existing) + synapse_result = self._rename_synapse_guests( + client, + seeder_token, + existing, + mas_result.usernames, + ) + db_renamed = self._db_rename_numeric(existing) finally: self._mas_revoke_session(client, admin_token, seeder_session) + renamed = mas_result.renamed + synapse_result.renamed + db_renamed + pruned = synapse_result.pruned + skipped = mas_result.skipped processed = renamed + pruned + skipped summary = CommsSummary(processed, renamed, pruned, skipped) logger.info( @@ -621,7 +680,7 @@ class CommsService: "password": password, }, ) - if resp.status_code != 200: + if resp.status_code != HTTP_OK: raise RuntimeError(f"login failed: {resp.status_code} {resp.text}") payload = resp.json() token = payload.get("access_token") @@ -644,7 +703,7 @@ class CommsService: f"{settings.comms_synapse_base}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/state/m.room.pinned_events", headers=_auth(token), ) - if resp.status_code == 404: + if resp.status_code == HTTP_NOT_FOUND: return [] resp.raise_for_status() pinned = resp.json().get("pinned", []) @@ -655,7 +714,7 @@ class CommsService: f"{settings.comms_synapse_base}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/event/{urllib.parse.quote(event_id)}", headers=_auth(token), ) - if resp.status_code == 404: + if resp.status_code == HTTP_NOT_FOUND: return None resp.raise_for_status() return resp.json() @@ -709,7 +768,7 @@ class CommsService: f"{settings.comms_synapse_base}/_matrix/client/v3/directory/room/{urllib.parse.quote(alias)}", headers=_auth(token), ) - if resp.status_code in (200, 202, 404): + if resp.status_code in (HTTP_OK, HTTP_ACCEPTED, HTTP_NOT_FOUND): return resp.raise_for_status() @@ -742,7 +801,7 @@ class CommsService: headers=_auth(token), json={"user_id": user_id}, ) - if resp.status_code in (200, 202): + if resp.status_code in (HTTP_OK, HTTP_ACCEPTED): return resp.raise_for_status() @@ -774,11 +833,11 @@ class CommsService: user_id = _canon_user(localpart, settings.comms_server_name) url = f"{settings.comms_synapse_base}/_synapse/admin/v2/users/{urllib.parse.quote(user_id)}" resp = client.get(url, headers=_auth(token)) - if resp.status_code == 200: + if resp.status_code == HTTP_OK: return payload = {"password": password, "admin": admin, "deactivated": False} create = client.put(url, headers=_auth(token), json=payload) - if create.status_code not in (200, 201): + if create.status_code not in (HTTP_OK, HTTP_CREATED): raise RuntimeError(f"create user {user_id} failed: {create.status_code} {create.text}") def _ensure_room(self, client: httpx.Client, token: str) -> str: @@ -788,7 +847,7 @@ class CommsService: f"{settings.comms_synapse_base}/_matrix/client/v3/directory/room/{alias_enc}", headers=_auth(token), ) - if exists.status_code == 200: + if exists.status_code == HTTP_OK: room_id = exists.json()["room_id"] else: create = client.post( @@ -806,7 +865,7 @@ class CommsService: }, }, ) - if create.status_code not in (200, 409): + if create.status_code not in (HTTP_OK, HTTP_CONFLICT): raise RuntimeError(f"create room failed: {create.status_code} {create.text}") exists = client.get( f"{settings.comms_synapse_base}/_matrix/client/v3/directory/room/{alias_enc}", diff --git a/ariadne/services/firefly.py b/ariadne/services/firefly.py index 1f83ea9..577341a 100644 --- a/ariadne/services/firefly.py +++ b/ariadne/services/firefly.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any import textwrap @@ -8,6 +10,17 @@ import httpx from ..k8s.exec import ExecError, PodExecutor from ..k8s.pods import PodSelectionError from ..settings import settings +from ..utils.logging import get_logger +from ..utils.passwords import random_password +from .keycloak_admin import keycloak_admin +from .mailu import mailu + + +HTTP_OK = 200 +FIREFLY_PASSWORD_ATTR = "firefly_password" +FIREFLY_PASSWORD_UPDATED_ATTR = "firefly_password_updated_at" + +logger = get_logger(__name__) _FIREFLY_SYNC_SCRIPT = textwrap.dedent( @@ -133,6 +146,208 @@ def _firefly_exec_command() -> str: return f"php <<'PHP'\n{_FIREFLY_SYNC_SCRIPT}\nPHP" +@dataclass(frozen=True) +class FireflySyncSummary: + processed: int + synced: int + skipped: int + failures: int + detail: str = "" + + +@dataclass +class FireflySyncCounters: + processed: int = 0 + synced: int = 0 + skipped: int = 0 + failures: int = 0 + + def status(self) -> str: + return "ok" if self.failures == 0 else "error" + + def summary(self, detail: str = "") -> FireflySyncSummary: + return FireflySyncSummary( + processed=self.processed, + synced=self.synced, + skipped=self.skipped, + failures=self.failures, + detail=detail, + ) + + +@dataclass(frozen=True) +class UserSyncOutcome: + status: str + detail: str = "" + + +@dataclass(frozen=True) +class UserIdentity: + username: str + user_id: str + + +@dataclass(frozen=True) +class FireflySyncInput: + username: str + mailu_email: str + password: str + password_generated: bool + updated_at: str + + +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 "" + + +def _should_skip_user(user: dict[str, Any], username: str) -> bool: + if not username or user.get("enabled") is False: + return True + if user.get("serviceAccountClientId") or username.startswith("service-account-"): + return True + return False + + +def _load_attrs(user_id: str, user: dict[str, Any]) -> dict[str, Any] | None: + attrs = user.get("attributes") + if isinstance(attrs, dict): + return attrs + try: + full = keycloak_admin.get_user(user_id) + except Exception: + return None + attrs = full.get("attributes") if isinstance(full, dict) else {} + return attrs if isinstance(attrs, dict) else {} + + +def _ensure_mailu_email(username: str, attrs: dict[str, Any], fallback_email: str) -> str | None: + mailu_email = mailu.resolve_mailu_email(username, attrs, fallback_email) + if not mailu_email: + return None + if _extract_attr(attrs, "mailu_email"): + return mailu_email + try: + keycloak_admin.set_user_attribute(username, "mailu_email", mailu_email) + except Exception: + return None + return mailu_email + + +def _ensure_firefly_password(username: str, attrs: dict[str, Any]) -> tuple[str | None, bool]: + password = _extract_attr(attrs, FIREFLY_PASSWORD_ATTR) + if password: + return password, False + password = random_password(24) + try: + keycloak_admin.set_user_attribute(username, FIREFLY_PASSWORD_ATTR, password) + except Exception: + return None, False + return password, True + + +def _set_firefly_updated_at(username: str) -> bool: + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + try: + keycloak_admin.set_user_attribute(username, FIREFLY_PASSWORD_UPDATED_ATTR, now_iso) + except Exception: + return False + return True + + +def _normalize_user(user: dict[str, Any]) -> tuple[UserSyncOutcome | None, UserIdentity | None]: + username = user.get("username") if isinstance(user.get("username"), str) else "" + if _should_skip_user(user, username): + return UserSyncOutcome("skipped"), None + user_id = user.get("id") if isinstance(user.get("id"), str) else "" + if not user_id: + return UserSyncOutcome("failed", "missing user id"), None + return None, UserIdentity(username, user_id) + + +def _load_attrs_or_outcome( + identity: UserIdentity, + user: dict[str, Any], +) -> tuple[dict[str, Any] | None, UserSyncOutcome | None]: + attrs = _load_attrs(identity.user_id, user) + if attrs is None: + return None, UserSyncOutcome("failed", "missing attributes") + return attrs, None + + +def _mailu_email_or_outcome( + identity: UserIdentity, + attrs: dict[str, Any], + user: dict[str, Any], +) -> tuple[str | None, UserSyncOutcome | None]: + mailu_email = _ensure_mailu_email( + identity.username, + attrs, + user.get("email") if isinstance(user.get("email"), str) else "", + ) + if not mailu_email: + return None, UserSyncOutcome("failed", "missing mailu email") + return mailu_email, None + + +def _firefly_password_or_outcome( + identity: UserIdentity, + attrs: dict[str, Any], +) -> tuple[str | None, bool, UserSyncOutcome | None]: + password, generated = _ensure_firefly_password(identity.username, attrs) + if not password: + return None, generated, UserSyncOutcome("failed", "missing firefly password") + return password, generated, None + + +def _should_skip_sync(password_generated: bool, updated_at: str) -> bool: + return not password_generated and bool(updated_at) + + +def _build_sync_input(user: dict[str, Any]) -> FireflySyncInput | UserSyncOutcome: + outcome, identity = _normalize_user(user) + attrs = None + mailu_email = None + password = None + password_generated = False + + if outcome is None and identity is not None: + attrs, outcome = _load_attrs_or_outcome(identity, user) + if outcome is None and attrs is not None: + mailu_email, outcome = _mailu_email_or_outcome(identity, attrs, user) + if outcome is None and mailu_email: + password, password_generated, outcome = _firefly_password_or_outcome(identity, attrs) + + if outcome: + return outcome + if identity is None: + return UserSyncOutcome("failed", "missing identity") + if attrs is None: + return UserSyncOutcome("failed", "missing attributes") + if not mailu_email: + return UserSyncOutcome("failed", "missing mailu email") + if not password: + return UserSyncOutcome("failed", "missing firefly password") + + updated_at = _extract_attr(attrs, FIREFLY_PASSWORD_UPDATED_ATTR) + return FireflySyncInput( + username=identity.username, + mailu_email=mailu_email, + password=password, + password_generated=password_generated, + updated_at=updated_at, + ) + + class FireflyService: def __init__(self) -> None: self._executor = PodExecutor( @@ -175,11 +390,59 @@ class FireflyService: try: with httpx.Client(timeout=settings.firefly_cron_timeout_sec) as client: resp = client.get(url) - if resp.status_code != 200: + if resp.status_code != HTTP_OK: return {"status": "error", "detail": f"status={resp.status_code}"} except Exception as exc: return {"status": "error", "detail": str(exc)} return {"status": "ok", "detail": "cron triggered"} + def _sync_user_entry(self, user: dict[str, Any]) -> UserSyncOutcome: + prepared = _build_sync_input(user) + if isinstance(prepared, UserSyncOutcome): + return prepared + if _should_skip_sync(prepared.password_generated, prepared.updated_at): + return UserSyncOutcome("skipped") + + result = self.sync_user(prepared.mailu_email, prepared.password, wait=True) + result_status = result.get("status") if isinstance(result, dict) else "error" + if result_status != "ok": + return UserSyncOutcome("failed", f"sync {result_status}") + if not _set_firefly_updated_at(prepared.username): + return UserSyncOutcome("failed", "failed to set updated_at") + + return UserSyncOutcome("synced") + + def sync_users(self) -> dict[str, Any]: + if not keycloak_admin.ready(): + return {"status": "error", "detail": "keycloak admin not configured"} + if not settings.firefly_namespace: + raise RuntimeError("firefly sync not configured") + + counters = FireflySyncCounters() + users = keycloak_admin.iter_users(page_size=200, brief=False) + for user in users: + outcome = self._sync_user_entry(user) + if outcome.status == "synced": + counters.processed += 1 + counters.synced += 1 + elif outcome.status == "skipped": + counters.skipped += 1 + else: + counters.failures += 1 + + summary = counters.summary() + logger.info( + "firefly user sync finished", + extra={ + "event": "firefly_user_sync", + "status": counters.status(), + "processed": summary.processed, + "synced": summary.synced, + "skipped": summary.skipped, + "failures": summary.failures, + }, + ) + return {"status": counters.status(), "summary": summary} + firefly = FireflyService() diff --git a/ariadne/services/mailu.py b/ariadne/services/mailu.py index 3a2e632..38049d5 100644 --- a/ariadne/services/mailu.py +++ b/ariadne/services/mailu.py @@ -1,12 +1,98 @@ from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime, timezone import time from typing import Any -import httpx import psycopg +from passlib.hash import bcrypt_sha256 from ..settings import settings +from ..utils.logging import get_logger +from ..utils.passwords import random_password +from .keycloak_admin import keycloak_admin + + +logger = get_logger(__name__) + +MAILU_ENABLED_ATTR = "mailu_enabled" +MAILU_EMAIL_ATTR = "mailu_email" +MAILU_APP_PASSWORD_ATTR = "mailu_app_password" + + +@dataclass(frozen=True) +class MailuSyncSummary: + processed: int + updated: int + skipped: int + failures: int + mailboxes: int + system_mailboxes: int + detail: str = "" + + +@dataclass(frozen=True) +class MailuUserSyncResult: + processed: int = 0 + updated: int = 0 + skipped: int = 0 + failures: int = 0 + mailboxes: int = 0 + + +@dataclass +class MailuSyncCounters: + processed: int = 0 + updated: int = 0 + skipped: int = 0 + failures: int = 0 + mailboxes: int = 0 + system_mailboxes: int = 0 + + def add(self, result: MailuUserSyncResult) -> None: + self.processed += result.processed + self.updated += result.updated + self.skipped += result.skipped + self.failures += result.failures + self.mailboxes += result.mailboxes + + def summary(self) -> MailuSyncSummary: + return MailuSyncSummary( + processed=self.processed, + updated=self.updated, + skipped=self.skipped, + failures=self.failures, + mailboxes=self.mailboxes, + system_mailboxes=self.system_mailboxes, + ) + + +def _extract_attr(attrs: Any, key: str) -> str | None: + if not isinstance(attrs, dict): + return None + raw = attrs.get(key) + if isinstance(raw, list): + for item in raw: + if isinstance(item, str) and item.strip(): + return item.strip() + return None + if isinstance(raw, str) and raw.strip(): + return raw.strip() + return None + + +def _display_name(user: dict[str, Any]) -> str: + parts = [] + for key in ("firstName", "lastName"): + value = user.get(key) + if isinstance(value, str) and value.strip(): + parts.append(value.strip()) + return " ".join(parts) + + +def _domain_matches(email: str) -> bool: + return email.lower().endswith(f"@{settings.mailu_domain.lower()}") class MailuService: @@ -19,12 +105,245 @@ class MailuService: "password": settings.mailu_db_password, } + def _connect(self) -> psycopg.Connection: + return psycopg.connect(**self._db_config) + + def ready(self) -> bool: + return bool( + settings.mailu_db_host + and settings.mailu_db_name + and settings.mailu_db_user + and settings.mailu_db_password + ) + + @staticmethod + def resolve_mailu_email( + username: str, + attributes: dict[str, Any] | None, + fallback_email: str = "", + ) -> str: + attrs = attributes or {} + explicit = _extract_attr(attrs, MAILU_EMAIL_ATTR) + if explicit: + return explicit + if fallback_email and fallback_email.lower().endswith(f"@{settings.mailu_domain.lower()}"): + return fallback_email + return f"{username}@{settings.mailu_domain}" + + def _mailu_enabled(self, attrs: dict[str, Any], updates: dict[str, list[str]]) -> bool: + raw = _extract_attr(attrs, MAILU_ENABLED_ATTR) + if raw is None: + updates[MAILU_ENABLED_ATTR] = ["true"] + return True + return raw.strip().lower() in {"1", "true", "yes", "y", "on"} + + @staticmethod + def _username(user: dict[str, Any]) -> str: + return user.get("username") if isinstance(user.get("username"), str) else "" + + @staticmethod + def _user_id(user: dict[str, Any]) -> str: + return user.get("id") if isinstance(user.get("id"), str) else "" + + @staticmethod + def _is_service_account(user: dict[str, Any], username: str) -> bool: + return bool(user.get("serviceAccountClientId") or username.startswith("service-account-")) + + @staticmethod + def _log_sync_error(username: str, detail: str) -> None: + logger.info( + "mailu sync error", + extra={ + "event": "mailu_sync", + "status": "error", + "detail": detail, + "username": username, + }, + ) + + def _prepare_updates( + self, + attrs: dict[str, Any], + mailu_email: str, + ) -> tuple[bool, dict[str, list[str]], str]: + updates: dict[str, list[str]] = {} + if not _extract_attr(attrs, MAILU_EMAIL_ATTR): + updates[MAILU_EMAIL_ATTR] = [mailu_email] + + enabled = self._mailu_enabled(attrs, updates) + + app_password = _extract_attr(attrs, MAILU_APP_PASSWORD_ATTR) + if not app_password: + app_password = random_password(24) + updates[MAILU_APP_PASSWORD_ATTR] = [app_password] + + return enabled, updates, app_password + + def _apply_updates(self, user_id: str, updates: dict[str, list[str]], username: str) -> bool: + if not updates: + return True + try: + keycloak_admin.update_user_safe(user_id, {"attributes": updates}) + except Exception as exc: + self._log_sync_error(username, str(exc)) + return False + return True + + def _should_skip_user(self, user: dict[str, Any], username: str) -> bool: + if not username or user.get("enabled") is False: + return True + return self._is_service_account(user, username) + + def _sync_user(self, conn: psycopg.Connection, user: dict[str, Any]) -> MailuUserSyncResult: + username = self._username(user) + if self._should_skip_user(user, username): + return MailuUserSyncResult(skipped=1) + + user_id = self._user_id(user) + if not user_id: + return MailuUserSyncResult(failures=1) + + attrs = user.get("attributes") + if not isinstance(attrs, dict): + attrs = {} + + mailu_email = self.resolve_mailu_email( + username, + attrs, + user.get("email") if isinstance(user.get("email"), str) else "", + ) + enabled, updates, app_password = self._prepare_updates(attrs, mailu_email) + + if not enabled: + return MailuUserSyncResult(skipped=1) + if not self._apply_updates(user_id, updates, username): + return MailuUserSyncResult(failures=1) + updated = 1 if updates else 0 + + mailbox_ok = False + failed = False + try: + mailbox_ok = self._ensure_mailbox(conn, mailu_email, app_password, _display_name(user)) + except Exception as exc: + self._log_sync_error(username, str(exc)) + failed = True + + result = MailuUserSyncResult(skipped=1, updated=updated) + if failed: + result = MailuUserSyncResult(failures=1, updated=updated) + elif mailbox_ok: + result = MailuUserSyncResult(processed=1, updated=updated, mailboxes=1) + return result + + def _ensure_mailbox( + self, + conn: psycopg.Connection, + email: str, + password: str, + display_name: str, + ) -> bool: + email = (email or "").strip() + if not email or "@" not in email: + return False + if not _domain_matches(email): + return False + + localpart, domain = email.split("@", 1) + hashed = bcrypt_sha256.hash(password) + now = datetime.now(timezone.utc) + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO "user" ( + email, localpart, domain_name, password, + quota_bytes, quota_bytes_used, + global_admin, enabled, enable_imap, enable_pop, allow_spoofing, + forward_enabled, forward_destination, forward_keep, + reply_enabled, reply_subject, reply_body, reply_startdate, reply_enddate, + displayed_name, spam_enabled, spam_mark_as_read, spam_threshold, + change_pw_next_login, created_at, updated_at, comment + ) + VALUES ( + %(email)s, %(localpart)s, %(domain)s, %(password)s, + %(quota)s, 0, + false, true, true, true, false, + false, '', true, + false, NULL, NULL, DATE '1900-01-01', DATE '2999-12-31', + %(display)s, true, true, 80, + false, CURRENT_DATE, %(now)s, '' + ) + ON CONFLICT (email) DO UPDATE + SET password = EXCLUDED.password, + enabled = true, + updated_at = EXCLUDED.updated_at + """, + { + "email": email, + "localpart": localpart, + "domain": domain, + "password": hashed, + "quota": settings.mailu_default_quota, + "display": display_name or localpart, + "now": now, + }, + ) + return True + + def _ensure_system_mailboxes(self, conn: psycopg.Connection) -> int: + if not settings.mailu_system_users: + return 0 + if not settings.mailu_system_password: + logger.info( + "mailu system users configured but password missing", + extra={"event": "mailu_sync", "status": "error", "detail": "system password missing"}, + ) + return 0 + + ensured = 0 + for email in settings.mailu_system_users: + if not email: + continue + if self._ensure_mailbox(conn, email, settings.mailu_system_password, email.split("@")[0]): + ensured += 1 + return ensured + + def sync(self, reason: str, force: bool = False) -> MailuSyncSummary: + if not keycloak_admin.ready(): + raise RuntimeError("keycloak admin client not configured") + if not self.ready(): + raise RuntimeError("mailu database not configured") + + counters = MailuSyncCounters() + users = keycloak_admin.iter_users(page_size=200, brief=False) + with self._connect() as conn: + for user in users: + counters.add(self._sync_user(conn, user)) + counters.system_mailboxes = self._ensure_system_mailboxes(conn) + + summary = counters.summary() + logger.info( + "mailu sync finished", + extra={ + "event": "mailu_sync", + "status": "ok" if summary.failures == 0 else "error", + "processed": summary.processed, + "updated": summary.updated, + "skipped": summary.skipped, + "failures": summary.failures, + "mailboxes": summary.mailboxes, + "system_mailboxes": summary.system_mailboxes, + "reason": reason, + "force": force, + }, + ) + return summary + def mailbox_exists(self, email: str) -> bool: email = (email or "").strip() if not email: return False try: - with psycopg.connect(**self._db_config) as conn: + with self._connect() as conn: with conn.cursor() as cur: cur.execute('SELECT 1 FROM "user" WHERE email = %s LIMIT 1', (email,)) return cur.fetchone() is not None @@ -39,28 +358,5 @@ class MailuService: time.sleep(2) return False - def sync(self, reason: str, force: bool = False) -> None: - if not settings.mailu_sync_url: - return - with httpx.Client(timeout=settings.mailu_sync_wait_timeout_sec) as client: - resp = client.post( - settings.mailu_sync_url, - json={"ts": int(time.time()), "wait": True, "reason": reason, "force": force}, - ) - if resp.status_code != 200: - raise RuntimeError(f"mailu sync failed status={resp.status_code}") - - @staticmethod - def resolve_mailu_email(username: str, attributes: dict[str, Any] | None) -> str: - attrs = attributes or {} - raw = attrs.get("mailu_email") if isinstance(attrs, dict) else None - if isinstance(raw, list): - for item in raw: - if isinstance(item, str) and item.strip(): - return item.strip() - if isinstance(raw, str) and raw.strip(): - return raw.strip() - return f"{username}@{settings.mailu_domain}" - mailu = MailuService() diff --git a/ariadne/services/mailu_events.py b/ariadne/services/mailu_events.py new file mode 100644 index 0000000..6e57934 --- /dev/null +++ b/ariadne/services/mailu_events.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import dataclass +import threading +import time +from typing import Any, Callable + +from ..metrics.metrics import record_task_run +from ..settings import settings +from ..utils.logging import get_logger, task_context +from .mailu import mailu + + +logger = get_logger(__name__) + + +@dataclass(frozen=True) +class MailuEventResult: + status: str + detail: str = "" + triggered: bool = False + + +def _parse_bool(payload: dict[str, Any] | None, key: str) -> bool: + if not isinstance(payload, dict): + return False + value = payload.get(key) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "y", "on"} + return False + + +def _event_reason(payload: dict[str, Any] | None) -> str: + if not isinstance(payload, dict): + return "keycloak_event" + for key in ("eventType", "type", "event_type"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return f"keycloak_event:{value.strip()}" + return "keycloak_event" + + +def _event_context(payload: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + context: dict[str, Any] = {} + for key in ("eventType", "type", "userId", "clientId", "realmId"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + context[key] = value.strip() + return context + + +class MailuEventRunner: + def __init__( + self, + min_interval_sec: float, + wait_timeout_sec: float, + runner: Callable[[str, bool], tuple[str, str]] | None = None, + thread_factory: Callable[..., threading.Thread] = threading.Thread, + ) -> None: + self._min_interval_sec = min_interval_sec + self._wait_timeout_sec = wait_timeout_sec + self._runner = runner or self._default_runner + self._thread_factory = thread_factory + self._lock = threading.Lock() + self._last_run = 0.0 + self._last_status = "unknown" + self._last_detail = "" + self._running = False + self._done = threading.Event() + self._done.set() + + def _default_runner(self, reason: str, force: bool) -> tuple[str, str]: + summary = mailu.sync(reason, force=force) + status = "ok" if summary.failures == 0 else "error" + return status, summary.detail + + def _run_sync(self, reason: str, force: bool) -> None: + started = time.monotonic() + status = "error" + detail = "" + with task_context("mailu_event"): + try: + status, detail = self._runner(reason, force) + except Exception as exc: # noqa: BLE001 + status = "error" + detail = str(exc).strip() or "mailu sync failed" + finally: + duration = time.monotonic() - started + record_task_run("mailu_event", status, duration) + with self._lock: + self._running = False + self._last_run = time.time() + self._last_status = status + self._last_detail = detail + self._done.set() + + def _trigger(self, reason: str, force: bool) -> bool: + with self._lock: + now = time.time() + if self._running: + return False + if not force and now - self._last_run < self._min_interval_sec: + return False + self._running = True + self._done.clear() + + thread = self._thread_factory( + target=self._run_sync, + args=(reason, force), + daemon=True, + ) + thread.start() + return True + + def handle_event(self, payload: dict[str, Any] | None) -> tuple[int, dict[str, Any]]: + wait = _parse_bool(payload, "wait") + force = _parse_bool(payload, "force") + reason = _event_reason(payload) + context = _event_context(payload) + + triggered = self._trigger(reason, force=force) + logger.info( + "mailu event received", + extra={"event": "mailu_event", "status": "queued" if triggered else "skipped", **context}, + ) + + if not wait: + result = MailuEventResult( + status="accepted" if triggered else "skipped", + triggered=triggered, + ) + return 202, {"status": result.status, "triggered": result.triggered} + + self._done.wait(timeout=self._wait_timeout_sec) + with self._lock: + running = self._running + status = self._last_status + detail = self._last_detail + if running: + return 200, {"status": "running"} + return (200 if status == "ok" else 500), {"status": status, "detail": detail} + + +mailu_events = MailuEventRunner( + min_interval_sec=settings.mailu_event_min_interval_sec, + wait_timeout_sec=settings.mailu_sync_wait_timeout_sec, +) diff --git a/ariadne/services/nextcloud.py b/ariadne/services/nextcloud.py index 55740c0..43c2c6c 100644 --- a/ariadne/services/nextcloud.py +++ b/ariadne/services/nextcloud.py @@ -74,6 +74,29 @@ class NextcloudMailSyncSummary: detail: str = "" +@dataclass +class MailSyncCounters: + processed: int = 0 + created: int = 0 + updated: int = 0 + deleted: int = 0 + skipped: int = 0 + failures: int = 0 + + def summary(self) -> NextcloudMailSyncSummary: + return NextcloudMailSyncSummary( + processed=self.processed, + created=self.created, + updated=self.updated, + deleted=self.deleted, + skipped=self.skipped, + failures=self.failures, + ) + + def status(self) -> str: + return "ok" if self.failures == 0 else "error" + + class NextcloudService: def __init__(self) -> None: self._executor = PodExecutor( @@ -151,6 +174,253 @@ class NextcloudService: except Exception: return + def _collect_users(self, username: str | None) -> list[dict[str, Any]]: + if username is not None: + user = keycloak_admin.find_user(username) + return [user] if user else [] + return list(keycloak_admin.iter_users(page_size=200, brief=False)) + + def _normalize_user(self, user: dict[str, Any]) -> tuple[str, str, dict[str, Any]] | None: + username_val = user.get("username") if isinstance(user.get("username"), str) else "" + username_val = username_val.strip() + if not username_val: + return None + if user.get("enabled") is False: + return None + if user.get("serviceAccountClientId") or username_val.startswith("service-account-"): + return None + + user_id = user.get("id") if isinstance(user.get("id"), str) else "" + full_user = user + if user_id: + try: + full_user = keycloak_admin.get_user(user_id) + except Exception: + full_user = user + return username_val, user_id, full_user + + def _list_mail_accounts_safe( + self, + username: str, + counters: MailSyncCounters, + ) -> list[tuple[str, str]] | None: + try: + return self._list_mail_accounts(username) + except Exception as exc: + counters.failures += 1 + logger.info( + "nextcloud mail export failed", + extra={"event": "nextcloud_mail_export", "status": "error", "detail": str(exc)}, + ) + return None + + def _select_primary_account( + self, + mailu_accounts: list[tuple[str, str]], + mailu_email: str, + ) -> tuple[str, str]: + primary_id = "" + primary_email = "" + for account_id, account_email in mailu_accounts: + if not primary_id: + primary_id = account_id + primary_email = account_email + if account_email.lower() == mailu_email.lower(): + primary_id = account_id + primary_email = account_email + break + return primary_id, primary_email + + def _update_mail_account( + self, + username: str, + primary_id: str, + mailu_email: str, + app_pw: str, + ) -> bool: + try: + self._occ( + [ + "mail:account:update", + "-q", + primary_id, + "--name", + username, + "--email", + mailu_email, + "--imap-host", + settings.mailu_host, + "--imap-port", + "993", + "--imap-ssl-mode", + "ssl", + "--imap-user", + mailu_email, + "--imap-password", + app_pw, + "--smtp-host", + settings.mailu_host, + "--smtp-port", + "587", + "--smtp-ssl-mode", + "tls", + "--smtp-user", + mailu_email, + "--smtp-password", + app_pw, + "--auth-method", + "password", + ] + ) + return True + except Exception: + return False + + def _create_mail_account(self, username: str, mailu_email: str, app_pw: str) -> bool: + try: + self._occ( + [ + "mail:account:create", + "-q", + username, + username, + mailu_email, + settings.mailu_host, + "993", + "ssl", + mailu_email, + app_pw, + settings.mailu_host, + "587", + "tls", + mailu_email, + app_pw, + "password", + ] + ) + return True + except Exception: + return False + + def _delete_extra_accounts( + self, + mailu_accounts: list[tuple[str, str]], + primary_id: str, + counters: MailSyncCounters, + ) -> int: + deleted = 0 + for account_id, _account_email in mailu_accounts: + if account_id == primary_id: + continue + try: + self._occ(["mail:account:delete", "-q", account_id]) + deleted += 1 + except Exception: + counters.failures += 1 + return deleted + + def _mailu_accounts(self, accounts: list[tuple[str, str]]) -> list[tuple[str, str]]: + return [ + (account_id, email) + for account_id, email in accounts + if email.lower().endswith(f"@{settings.mailu_domain.lower()}") + ] + + def _summarize_mail_accounts( + self, + accounts: list[tuple[str, str]], + mailu_email: str, + ) -> tuple[int, str, list[str]]: + mailu_accounts = self._mailu_accounts(accounts) + account_count = len(mailu_accounts) + primary_email = "" + editor_mode_ids: list[str] = [] + for account_id, account_email in mailu_accounts: + editor_mode_ids.append(account_id) + if account_email.lower() == mailu_email.lower(): + primary_email = account_email + break + if not primary_email: + primary_email = account_email + return account_count, primary_email, editor_mode_ids + + def _mail_sync_context( + self, + user: dict[str, Any], + counters: MailSyncCounters, + ) -> tuple[str, str, str, str] | None: + normalized = self._normalize_user(user) + if not normalized: + counters.skipped += 1 + return None + username, user_id, full_user = normalized + attrs = full_user.get("attributes") if isinstance(full_user.get("attributes"), dict) else {} + mailu_email = _resolve_mailu_email(username, full_user) + app_pw = _extract_attr(attrs, "mailu_app_password") + if not mailu_email or not app_pw: + counters.skipped += 1 + return None + if mailu_email and not _extract_attr(attrs, "mailu_email"): + try: + keycloak_admin.set_user_attribute(username, "mailu_email", mailu_email) + except Exception: + pass + return username, user_id, mailu_email, app_pw + + def _sync_mail_accounts( + self, + username: str, + mailu_email: str, + app_pw: str, + accounts: list[tuple[str, str]], + counters: MailSyncCounters, + ) -> bool: + mailu_accounts = self._mailu_accounts(accounts) + if mailu_accounts: + primary_id, _primary_email = self._select_primary_account(mailu_accounts, mailu_email) + if not self._update_mail_account(username, primary_id, mailu_email, app_pw): + counters.failures += 1 + return False + counters.updated += 1 + counters.deleted += self._delete_extra_accounts(mailu_accounts, primary_id, counters) + else: + if not self._create_mail_account(username, mailu_email, app_pw): + counters.failures += 1 + return False + counters.created += 1 + return True + + def _apply_mail_metadata( + self, + user_id: str, + mailu_email: str, + accounts: list[tuple[str, str]], + ) -> None: + account_count, primary_email, editor_mode_ids = self._summarize_mail_accounts(accounts, mailu_email) + self._set_editor_mode_richtext(editor_mode_ids) + if user_id: + self._set_user_mail_meta(user_id, primary_email, account_count) + + def _sync_user_mail(self, user: dict[str, Any], counters: MailSyncCounters) -> None: + context = self._mail_sync_context(user, counters) + if not context: + return + username, user_id, mailu_email, app_pw = context + + accounts = self._list_mail_accounts_safe(username, counters) + if accounts is None: + return + + counters.processed += 1 + if not self._sync_mail_accounts(username, mailu_email, app_pw, accounts, counters): + return + + accounts_after = self._list_mail_accounts_safe(username, counters) + if accounts_after is None: + return + + self._apply_mail_metadata(user_id, mailu_email, accounts_after) + def sync_mail(self, username: str | None = None, wait: bool = True) -> dict[str, Any]: if not settings.nextcloud_namespace: raise RuntimeError("nextcloud mail sync not configured") @@ -162,198 +432,31 @@ class NextcloudService: if not keycloak_admin.ready(): return {"status": "error", "detail": "keycloak admin not configured"} - users: list[dict[str, Any]] - if cleaned_username is not None: - user = keycloak_admin.find_user(cleaned_username) - if not user: - return {"status": "ok", "detail": "no matching user"} - users = [user] - else: - users = keycloak_admin.iter_users(page_size=200, brief=False) - - processed = created = updated = deleted = skipped = failures = 0 + users = self._collect_users(cleaned_username) + if cleaned_username is not None and not users: + return {"status": "ok", "detail": "no matching user"} + counters = MailSyncCounters() for user in users: - username_val = user.get("username") if isinstance(user.get("username"), str) else "" - username_val = username_val.strip() - if not username_val: - skipped += 1 - continue - if user.get("enabled") is False: - skipped += 1 - continue - if user.get("serviceAccountClientId") or username_val.startswith("service-account-"): - skipped += 1 - continue + self._sync_user_mail(user, counters) - user_id = user.get("id") if isinstance(user.get("id"), str) else "" - full_user = user - if user_id: - try: - full_user = keycloak_admin.get_user(user_id) - except Exception: - full_user = user - - attrs = full_user.get("attributes") if isinstance(full_user.get("attributes"), dict) else {} - mailu_email = _resolve_mailu_email(username_val, full_user) - app_pw = _extract_attr(attrs, "mailu_app_password") - if not mailu_email or not app_pw: - skipped += 1 - continue - if mailu_email and not _extract_attr(attrs, "mailu_email"): - try: - keycloak_admin.set_user_attribute(username_val, "mailu_email", mailu_email) - except Exception: - pass - - try: - accounts = self._list_mail_accounts(username_val) - except Exception as exc: - failures += 1 - logger.info( - "nextcloud mail export failed", - extra={"event": "nextcloud_mail_export", "status": "error", "detail": str(exc)}, - ) - continue - - processed += 1 - mailu_accounts = [(aid, email) for aid, email in accounts if email.lower().endswith(f"@{settings.mailu_domain.lower()}")] - - primary_id = "" - primary_email = "" - for account_id, account_email in mailu_accounts: - if not primary_id: - primary_id = account_id - primary_email = account_email - if account_email.lower() == mailu_email.lower(): - primary_id = account_id - primary_email = account_email - break - - if mailu_accounts: - try: - self._occ( - [ - "mail:account:update", - "-q", - primary_id, - "--name", - username_val, - "--email", - mailu_email, - "--imap-host", - settings.mailu_host, - "--imap-port", - "993", - "--imap-ssl-mode", - "ssl", - "--imap-user", - mailu_email, - "--imap-password", - app_pw, - "--smtp-host", - settings.mailu_host, - "--smtp-port", - "587", - "--smtp-ssl-mode", - "tls", - "--smtp-user", - mailu_email, - "--smtp-password", - app_pw, - "--auth-method", - "password", - ] - ) - updated += 1 - except Exception: - failures += 1 - continue - - for account_id, account_email in mailu_accounts: - if account_id == primary_id: - continue - try: - self._occ(["mail:account:delete", "-q", account_id]) - deleted += 1 - except Exception: - failures += 1 - else: - try: - self._occ( - [ - "mail:account:create", - "-q", - username_val, - username_val, - mailu_email, - settings.mailu_host, - "993", - "ssl", - mailu_email, - app_pw, - settings.mailu_host, - "587", - "tls", - mailu_email, - app_pw, - "password", - ] - ) - created += 1 - except Exception: - failures += 1 - continue - - try: - accounts_after = self._list_mail_accounts(username_val) - except Exception: - failures += 1 - continue - - mailu_accounts_after = [ - (aid, email) for aid, email in accounts_after if email.lower().endswith(f"@{settings.mailu_domain.lower()}") - ] - account_count = len(mailu_accounts_after) - primary_email_after = "" - editor_mode_ids = [] - for account_id, account_email in mailu_accounts_after: - editor_mode_ids.append(account_id) - if account_email.lower() == mailu_email.lower(): - primary_email_after = account_email - break - if not primary_email_after: - primary_email_after = account_email - - self._set_editor_mode_richtext(editor_mode_ids) - if user_id: - self._set_user_mail_meta(user_id, primary_email_after, account_count) - - summary = NextcloudMailSyncSummary( - processed=processed, - created=created, - updated=updated, - deleted=deleted, - skipped=skipped, - failures=failures, - ) + summary = counters.summary() logger.info( "nextcloud mail sync finished", extra={ "event": "nextcloud_mail_sync", - "status": "ok" if failures == 0 else "error", - "processed_count": processed, - "created_count": created, - "updated_count": updated, - "deleted_count": deleted, - "skipped_count": skipped, - "failures_count": failures, + "status": counters.status(), + "processed_count": counters.processed, + "created_count": counters.created, + "updated_count": counters.updated, + "deleted_count": counters.deleted, + "skipped_count": counters.skipped, + "failures_count": counters.failures, }, ) - status = "ok" if failures == 0 else "error" - return {"status": status, "summary": summary} + return {"status": counters.status(), "summary": summary} def _run_shell(self, script: str, check: bool = True) -> None: self._executor.exec( diff --git a/ariadne/services/opensearch_prune.py b/ariadne/services/opensearch_prune.py index 6e617c4..5eef2bc 100644 --- a/ariadne/services/opensearch_prune.py +++ b/ariadne/services/opensearch_prune.py @@ -20,6 +20,8 @@ _UNITS = { "tb": 1024**4, } +HTTP_NOT_FOUND = 404 + def parse_size(value: str) -> int: if not value: @@ -49,7 +51,7 @@ def _fetch_indices(client: httpx.Client, pattern: str) -> list[dict[str, Any]]: url = f"{settings.opensearch_url}/_cat/indices/{pattern}" params = {"format": "json", "h": "index,store.size,creation.date"} resp = client.get(url, params=params) - if resp.status_code == 404: + if resp.status_code == HTTP_NOT_FOUND: return [] resp.raise_for_status() payload = resp.json() diff --git a/ariadne/services/vault.py b/ariadne/services/vault.py index 1f57078..b854de7 100644 --- a/ariadne/services/vault.py +++ b/ariadne/services/vault.py @@ -380,6 +380,98 @@ class VaultService: resp = client.request("POST", f"/v1/auth/kubernetes/role/{role['role']}", json=payload) resp.raise_for_status() + def _vault_ready(self) -> VaultResult | None: + try: + status = self._health(VaultClient(settings.vault_addr)) + except Exception as exc: # noqa: BLE001 + return VaultResult("error", str(exc)) + + if not status.get("initialized"): + return VaultResult("skip", "vault not initialized") + if status.get("sealed"): + return VaultResult("skip", "vault sealed") + return None + + def _validate_oidc_settings(self) -> str | None: + if not settings.vault_oidc_discovery_url: + return "oidc discovery url missing" + if not settings.vault_oidc_client_id or not settings.vault_oidc_client_secret: + return "oidc client credentials missing" + return None + + def _configure_oidc(self, client: VaultClient) -> None: + resp = client.request( + "POST", + "/v1/auth/oidc/config", + json={ + "oidc_discovery_url": settings.vault_oidc_discovery_url, + "oidc_client_id": settings.vault_oidc_client_id, + "oidc_client_secret": settings.vault_oidc_client_secret, + "default_role": settings.vault_oidc_default_role or "admin", + }, + ) + resp.raise_for_status() + + def _tune_oidc_listing(self, client: VaultClient) -> None: + try: + client.request( + "POST", + "/v1/sys/auth/oidc/tune", + json={"listing_visibility": "unauth"}, + ) + except Exception: + pass + + def _oidc_context(self) -> dict[str, Any]: + scopes = settings.vault_oidc_scopes or "openid profile email groups" + scope_parts = [part for part in scopes.replace(" ", ",").split(",") if part] + scopes_csv = ",".join(dict.fromkeys(scope_parts)) + return { + "scopes_csv": scopes_csv, + "redirect_uris": _split_csv(settings.vault_oidc_redirect_uris), + "bound_audiences": settings.vault_oidc_bound_audiences or settings.vault_oidc_client_id, + "bound_claims_type": settings.vault_oidc_bound_claims_type or "string", + "user_claim": settings.vault_oidc_user_claim or "preferred_username", + "groups_claim": settings.vault_oidc_groups_claim or "groups", + } + + def _oidc_roles(self) -> list[tuple[str, str, str]]: + admin_group = settings.vault_oidc_admin_group or "admin" + admin_policies = settings.vault_oidc_admin_policies or "default,vault-admin" + dev_group = settings.vault_oidc_dev_group or "dev" + dev_policies = settings.vault_oidc_dev_policies or "default,dev-kv" + user_group = settings.vault_oidc_user_group or dev_group + user_policies = ( + settings.vault_oidc_user_policies + or settings.vault_oidc_token_policies + or dev_policies + ) + return [ + ("admin", admin_group, admin_policies), + ("dev", dev_group, dev_policies), + ("user", user_group, user_policies), + ] + + def _oidc_role_payload( + self, + context: dict[str, Any], + groups: str, + policies: str, + ) -> dict[str, Any] | None: + group_list = _split_csv(groups) + if not group_list or not policies: + return None + return { + "user_claim": context["user_claim"], + "oidc_scopes": context["scopes_csv"], + "token_policies": policies, + "bound_audiences": context["bound_audiences"], + "bound_claims": {context["groups_claim"]: group_list}, + "bound_claims_type": context["bound_claims_type"], + "groups_claim": context["groups_claim"], + "allowed_redirect_uris": context["redirect_uris"], + } + def sync_k8s_auth(self, wait: bool = True) -> dict[str, Any]: try: status = self._health(VaultClient(settings.vault_addr)) @@ -433,81 +525,24 @@ class VaultService: return VaultResult("ok", "k8s auth configured").__dict__ def sync_oidc(self, wait: bool = True) -> dict[str, Any]: - try: - status = self._health(VaultClient(settings.vault_addr)) - except Exception as exc: # noqa: BLE001 - return VaultResult("error", str(exc)).__dict__ + status = self._vault_ready() + if status: + return status.__dict__ - if not status.get("initialized"): - return VaultResult("skip", "vault not initialized").__dict__ - if status.get("sealed"): - return VaultResult("skip", "vault sealed").__dict__ - - if not settings.vault_oidc_discovery_url: - return VaultResult("error", "oidc discovery url missing").__dict__ - if not settings.vault_oidc_client_id or not settings.vault_oidc_client_secret: - return VaultResult("error", "oidc client credentials missing").__dict__ + settings_error = self._validate_oidc_settings() + if settings_error: + return VaultResult("error", settings_error).__dict__ client = self._client() self._ensure_auth_enabled(client, "oidc", "oidc") + self._configure_oidc(client) + self._tune_oidc_listing(client) - resp = client.request( - "POST", - "/v1/auth/oidc/config", - json={ - "oidc_discovery_url": settings.vault_oidc_discovery_url, - "oidc_client_id": settings.vault_oidc_client_id, - "oidc_client_secret": settings.vault_oidc_client_secret, - "default_role": settings.vault_oidc_default_role or "admin", - }, - ) - resp.raise_for_status() - - try: - client.request( - "POST", - "/v1/sys/auth/oidc/tune", - json={"listing_visibility": "unauth"}, - ) - except Exception: - pass - - scopes = settings.vault_oidc_scopes or "openid profile email groups" - scope_parts = [part for part in scopes.replace(" ", ",").split(",") if part] - scopes_csv = ",".join(dict.fromkeys(scope_parts)) - redirect_uris = _split_csv(settings.vault_oidc_redirect_uris) - bound_audiences = settings.vault_oidc_bound_audiences or settings.vault_oidc_client_id - bound_claims_type = settings.vault_oidc_bound_claims_type or "string" - - admin_group = settings.vault_oidc_admin_group or "admin" - admin_policies = settings.vault_oidc_admin_policies or "default,vault-admin" - dev_group = settings.vault_oidc_dev_group or "dev" - dev_policies = settings.vault_oidc_dev_policies or "default,dev-kv" - user_group = settings.vault_oidc_user_group or dev_group - user_policies = ( - settings.vault_oidc_user_policies - or settings.vault_oidc_token_policies - or dev_policies - ) - - for role_name, groups, policies in ( - ("admin", admin_group, admin_policies), - ("dev", dev_group, dev_policies), - ("user", user_group, user_policies), - ): - group_list = _split_csv(groups) - if not group_list or not policies: + context = self._oidc_context() + for role_name, groups, policies in self._oidc_roles(): + payload = self._oidc_role_payload(context, groups, policies) + if not payload: continue - payload = { - "user_claim": settings.vault_oidc_user_claim or "preferred_username", - "oidc_scopes": scopes_csv, - "token_policies": policies, - "bound_audiences": bound_audiences, - "bound_claims": {settings.vault_oidc_groups_claim or "groups": group_list}, - "bound_claims_type": bound_claims_type, - "groups_claim": settings.vault_oidc_groups_claim or "groups", - "allowed_redirect_uris": redirect_uris, - } resp = client.request( "POST", f"/v1/auth/oidc/role/{role_name}", diff --git a/ariadne/services/vaultwarden.py b/ariadne/services/vaultwarden.py index b89e131..8c7e01c 100644 --- a/ariadne/services/vaultwarden.py +++ b/ariadne/services/vaultwarden.py @@ -10,6 +10,14 @@ from ..k8s.client import get_secret_value, get_json from ..settings import settings +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_NO_CONTENT = 204 +HTTP_BAD_REQUEST = 400 +HTTP_CONFLICT = 409 +HTTP_TOO_MANY_REQUESTS = 429 + + @dataclass(frozen=True) class VaultwardenInvite: ok: bool @@ -25,57 +33,83 @@ class VaultwardenService: self._admin_session_base_url: str = "" self._rate_limited_until: float = 0.0 - def invite_user(self, email: str) -> VaultwardenInvite: + @staticmethod + def _normalize_email(email: str) -> str | None: email = (email or "").strip() if not email or "@" not in email: - return VaultwardenInvite(ok=False, status="invalid_email", detail="email invalid") - if self._rate_limited_until and time.time() < self._rate_limited_until: - return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited") + return None + return email + def _rate_limited(self) -> VaultwardenInvite: + return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited") + + def _candidate_urls(self) -> list[str]: base_url = f"http://{settings.vaultwarden_service_host}" - fallback_url = "" + urls = [base_url] try: pod_ip = self._find_pod_ip(settings.vaultwarden_namespace, settings.vaultwarden_pod_label) - fallback_url = f"http://{pod_ip}:{settings.vaultwarden_pod_port}" + urls.append(f"http://{pod_ip}:{settings.vaultwarden_pod_port}") except Exception: - fallback_url = "" + pass + return [url for url in urls if url] + + @staticmethod + def _already_present(resp: httpx.Response) -> bool: + try: + body = resp.text or "" + except Exception: + body = "" + lowered = body.lower() + return any( + marker in lowered + for marker in ( + "already invited", + "already exists", + "already registered", + "user already exists", + ) + ) + + def _invite_via(self, base_url: str, email: str) -> VaultwardenInvite | None: + if not base_url: + return None + try: + session = self._admin_session(base_url) + resp = session.post("/admin/invite", json={"email": email}) + if resp.status_code == HTTP_TOO_MANY_REQUESTS: + self._rate_limited_until = time.time() + float(settings.vaultwarden_admin_rate_limit_backoff_sec) + result = self._rate_limited() + elif resp.status_code in {HTTP_OK, HTTP_CREATED, HTTP_NO_CONTENT}: + result = VaultwardenInvite(ok=True, status="invited", detail="invite created") + elif resp.status_code in {HTTP_BAD_REQUEST, HTTP_CONFLICT} and self._already_present(resp): + result = VaultwardenInvite(ok=True, status="already_present", detail="user already present") + else: + result = VaultwardenInvite(ok=False, status="error", detail=f"status {resp.status_code}") + except Exception as exc: + message = str(exc) + if "rate limited" in message.lower(): + result = self._rate_limited() + else: + result = VaultwardenInvite(ok=False, status="error", detail=message) + return result + + def invite_user(self, email: str) -> VaultwardenInvite: + email = self._normalize_email(email) + if not email: + return VaultwardenInvite(ok=False, status="invalid_email", detail="email invalid") + if self._rate_limited_until and time.time() < self._rate_limited_until: + return self._rate_limited() last_error = "" - for candidate in [base_url, fallback_url]: - if not candidate: - continue - try: - session = self._admin_session(candidate) - resp = session.post("/admin/invite", json={"email": email}) - if resp.status_code == 429: - self._rate_limited_until = time.time() + float(settings.vaultwarden_admin_rate_limit_backoff_sec) - return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited") - - if resp.status_code in {200, 201, 204}: - return VaultwardenInvite(ok=True, status="invited", detail="invite created") - - body = "" - try: - body = resp.text or "" - except Exception: - body = "" - if resp.status_code in {400, 409} and any( - marker in body.lower() - for marker in ( - "already invited", - "already exists", - "already registered", - "user already exists", - ) - ): - return VaultwardenInvite(ok=True, status="already_present", detail="user already present") - - last_error = f"status {resp.status_code}" - except Exception as exc: - last_error = str(exc) - if "rate limited" in last_error.lower(): - return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited") + for candidate in self._candidate_urls(): + result = self._invite_via(candidate, email) + if not result: continue + if result.ok: + return result + if result.status == "rate_limited": + return result + last_error = result.detail or last_error return VaultwardenInvite(ok=False, status="error", detail=last_error or "failed to invite") @@ -107,7 +141,7 @@ class VaultwardenService: headers={"User-Agent": "ariadne/1"}, ) resp = client.post("/admin", data={"token": token}) - if resp.status_code == 429: + if resp.status_code == HTTP_TOO_MANY_REQUESTS: self._rate_limited_until = now + float(settings.vaultwarden_admin_rate_limit_backoff_sec) raise RuntimeError("vaultwarden rate limited") resp.raise_for_status() diff --git a/ariadne/services/vaultwarden_sync.py b/ariadne/services/vaultwarden_sync.py index 60eb85f..de63158 100644 --- a/ariadne/services/vaultwarden_sync.py +++ b/ariadne/services/vaultwarden_sync.py @@ -28,6 +28,26 @@ class VaultwardenSyncSummary: detail: str = "" +@dataclass +class VaultwardenSyncCounters: + processed: int = 0 + created_or_present: int = 0 + skipped: int = 0 + failures: int = 0 + + def summary(self, detail: str = "") -> VaultwardenSyncSummary: + return VaultwardenSyncSummary( + processed=self.processed, + created_or_present=self.created_or_present, + skipped=self.skipped, + failures=self.failures, + detail=detail, + ) + + def status(self) -> str: + return "ok" if self.failures == 0 else "error" + + def _extract_attr(attrs: Any, key: str) -> str: if not isinstance(attrs, dict): return "" @@ -97,15 +117,117 @@ def _set_user_attribute(username: str, key: str, value: str) -> None: keycloak_admin.set_user_attribute(username, key, value) +def _normalize_user(user: dict[str, Any]) -> tuple[str, dict[str, Any]] | None: + username = (user.get("username") if isinstance(user.get("username"), str) else "") or "" + username = username.strip() + if not username: + return None + if user.get("enabled") is False: + return None + if user.get("serviceAccountClientId") or username.startswith("service-account-"): + return None + + user_id = (user.get("id") if isinstance(user.get("id"), str) else "") or "" + full_user = user + if user_id: + try: + full_user = keycloak_admin.get_user(user_id) + except Exception: + full_user = user + return username, full_user + + +def _current_sync_state(full_user: dict[str, Any]) -> tuple[str, str, float | None]: + current_status = _extract_attr(full_user.get("attributes"), VAULTWARDEN_STATUS_ATTR) + current_synced_at = _extract_attr(full_user.get("attributes"), VAULTWARDEN_SYNCED_AT_ATTR) + current_synced_ts = _parse_synced_at(current_synced_at) + return current_status, current_synced_at, current_synced_ts + + +def _cooldown_active(status: str, synced_ts: float | None) -> bool: + if status not in {"rate_limited", "error"} or not synced_ts: + return False + return time.time() - synced_ts < settings.vaultwarden_retry_cooldown_sec + + +def _set_sync_status(username: str, status: str) -> None: + try: + _set_user_attribute(username, VAULTWARDEN_STATUS_ATTR, status) + _set_user_attribute( + username, + VAULTWARDEN_SYNCED_AT_ATTR, + time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + ) + except Exception: + return + + +def _ensure_email_attrs(username: str, full_user: dict[str, Any], email: str) -> None: + try: + _set_user_attribute_if_missing(username, full_user, "mailu_email", email) + _set_user_attribute_if_missing(username, full_user, VAULTWARDEN_EMAIL_ATTR, email) + except Exception: + return + + +def _handle_existing_invite( + username: str, + current_status: str, + current_synced_at: str, + counters: VaultwardenSyncCounters, +) -> bool: + if current_status not in {"invited", "already_present"}: + return False + if not current_synced_at: + _set_sync_status(username, current_status) + counters.skipped += 1 + return True + + +def _sync_user( + user: dict[str, Any], + counters: VaultwardenSyncCounters, +) -> tuple[str | None, bool]: + status: str | None = None + ok = False + normalized = _normalize_user(user) + if not normalized: + counters.skipped += 1 + else: + username, full_user = normalized + current_status, current_synced_at, current_synced_ts = _current_sync_state(full_user) + if _cooldown_active(current_status, current_synced_ts): + counters.skipped += 1 + else: + email = _vaultwarden_email_for_user(full_user) + if not email: + counters.skipped += 1 + elif not mailu.mailbox_exists(email): + counters.skipped += 1 + else: + _ensure_email_attrs(username, full_user, email) + if _handle_existing_invite(username, current_status, current_synced_at, counters): + status = None + else: + counters.processed += 1 + result = vaultwarden.invite_user(email) + status = result.status + if result.ok: + counters.created_or_present += 1 + ok = True + else: + counters.failures += 1 + _set_sync_status(username, result.status) + return status, ok + + def run_vaultwarden_sync() -> VaultwardenSyncSummary: - processed = 0 - created = 0 - skipped = 0 - failures = 0 consecutive_failures = 0 + counters = VaultwardenSyncCounters() if not keycloak_admin.ready(): - summary = VaultwardenSyncSummary(0, 0, 0, 1, detail="keycloak admin not configured") + counters.failures = 1 + summary = counters.summary(detail="keycloak admin not configured") logger.info( "vaultwarden sync skipped", extra={"event": "vaultwarden_sync", "status": "error", "detail": summary.detail}, @@ -114,105 +236,27 @@ def run_vaultwarden_sync() -> VaultwardenSyncSummary: users = keycloak_admin.iter_users(page_size=200, brief=False) for user in users: - username = (user.get("username") if isinstance(user.get("username"), str) else "") or "" - username = username.strip() - if not username: - skipped += 1 + status, ok = _sync_user(user, counters) + if status is None: continue - - enabled = user.get("enabled") - if enabled is False: - skipped += 1 - continue - - if user.get("serviceAccountClientId") or username.startswith("service-account-"): - skipped += 1 - continue - - user_id = (user.get("id") if isinstance(user.get("id"), str) else "") or "" - full_user = user - if user_id: - try: - full_user = keycloak_admin.get_user(user_id) - except Exception: - full_user = user - - current_status = _extract_attr(full_user.get("attributes"), VAULTWARDEN_STATUS_ATTR) - current_synced_at = _extract_attr(full_user.get("attributes"), VAULTWARDEN_SYNCED_AT_ATTR) - current_synced_ts = _parse_synced_at(current_synced_at) - if current_status in {"rate_limited", "error"} and current_synced_ts: - if time.time() - current_synced_ts < settings.vaultwarden_retry_cooldown_sec: - skipped += 1 - continue - - email = _vaultwarden_email_for_user(full_user) - if not email: - skipped += 1 - continue - - if not mailu.mailbox_exists(email): - skipped += 1 - continue - - try: - _set_user_attribute_if_missing(username, full_user, "mailu_email", email) - _set_user_attribute_if_missing(username, full_user, VAULTWARDEN_EMAIL_ATTR, email) - except Exception: - pass - - if current_status in {"invited", "already_present"}: - if not current_synced_at: - try: - _set_user_attribute( - username, - VAULTWARDEN_SYNCED_AT_ATTR, - time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - ) - except Exception: - pass - skipped += 1 - continue - - processed += 1 - result = vaultwarden.invite_user(email) - if result.ok: - created += 1 + if ok: consecutive_failures = 0 - try: - _set_user_attribute(username, VAULTWARDEN_STATUS_ATTR, result.status) - _set_user_attribute( - username, - VAULTWARDEN_SYNCED_AT_ATTR, - time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - ) - except Exception: - pass - else: - failures += 1 - if result.status in {"rate_limited", "error"}: - consecutive_failures += 1 - try: - _set_user_attribute(username, VAULTWARDEN_STATUS_ATTR, result.status) - _set_user_attribute( - username, - VAULTWARDEN_SYNCED_AT_ATTR, - time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - ) - except Exception: - pass - if consecutive_failures >= settings.vaultwarden_failure_bailout: - break + continue + if status in {"rate_limited", "error"}: + consecutive_failures += 1 + if consecutive_failures >= settings.vaultwarden_failure_bailout: + break - summary = VaultwardenSyncSummary(processed, created, skipped, failures) + summary = counters.summary() logger.info( "vaultwarden sync finished", extra={ "event": "vaultwarden_sync", - "status": "ok" if failures == 0 else "error", - "processed": processed, - "created_or_present": created, - "skipped": skipped, - "failures": failures, + "status": counters.status(), + "processed": counters.processed, + "created_or_present": counters.created_or_present, + "skipped": counters.skipped, + "failures": counters.failures, }, ) return summary diff --git a/ariadne/services/wger.py b/ariadne/services/wger.py index 53826ae..5e29056 100644 --- a/ariadne/services/wger.py +++ b/ariadne/services/wger.py @@ -1,13 +1,24 @@ from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any import textwrap from ..k8s.exec import ExecError, PodExecutor from ..k8s.pods import PodSelectionError from ..settings import settings +from ..utils.logging import get_logger +from ..utils.passwords import random_password +from .keycloak_admin import keycloak_admin +from .mailu import mailu +WGER_PASSWORD_ATTR = "wger_password" +WGER_PASSWORD_UPDATED_ATTR = "wger_password_updated_at" + +logger = get_logger(__name__) + _WGER_SYNC_SCRIPT = textwrap.dedent( """ from __future__ import annotations @@ -136,6 +147,208 @@ def _wger_exec_command() -> str: return f"python3 - <<'PY'\n{_WGER_SYNC_SCRIPT}\nPY" +@dataclass(frozen=True) +class WgerSyncSummary: + processed: int + synced: int + skipped: int + failures: int + detail: str = "" + + +@dataclass +class WgerSyncCounters: + processed: int = 0 + synced: int = 0 + skipped: int = 0 + failures: int = 0 + + def status(self) -> str: + return "ok" if self.failures == 0 else "error" + + def summary(self, detail: str = "") -> WgerSyncSummary: + return WgerSyncSummary( + processed=self.processed, + synced=self.synced, + skipped=self.skipped, + failures=self.failures, + detail=detail, + ) + + +@dataclass(frozen=True) +class UserSyncOutcome: + status: str + detail: str = "" + + +@dataclass(frozen=True) +class UserIdentity: + username: str + user_id: str + + +@dataclass(frozen=True) +class WgerSyncInput: + username: str + mailu_email: str + password: str + password_generated: bool + updated_at: str + + +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 "" + + +def _should_skip_user(user: dict[str, Any], username: str) -> bool: + if not username or user.get("enabled") is False: + return True + if user.get("serviceAccountClientId") or username.startswith("service-account-"): + return True + return False + + +def _load_attrs(user_id: str, user: dict[str, Any]) -> dict[str, Any] | None: + attrs = user.get("attributes") + if isinstance(attrs, dict): + return attrs + try: + full = keycloak_admin.get_user(user_id) + except Exception: + return None + attrs = full.get("attributes") if isinstance(full, dict) else {} + return attrs if isinstance(attrs, dict) else {} + + +def _ensure_mailu_email(username: str, attrs: dict[str, Any], fallback_email: str) -> str | None: + mailu_email = mailu.resolve_mailu_email(username, attrs, fallback_email) + if not mailu_email: + return None + if _extract_attr(attrs, "mailu_email"): + return mailu_email + try: + keycloak_admin.set_user_attribute(username, "mailu_email", mailu_email) + except Exception: + return None + return mailu_email + + +def _ensure_wger_password(username: str, attrs: dict[str, Any]) -> tuple[str | None, bool]: + password = _extract_attr(attrs, WGER_PASSWORD_ATTR) + if password: + return password, False + password = random_password(20) + try: + keycloak_admin.set_user_attribute(username, WGER_PASSWORD_ATTR, password) + except Exception: + return None, False + return password, True + + +def _set_wger_updated_at(username: str) -> bool: + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + try: + keycloak_admin.set_user_attribute(username, WGER_PASSWORD_UPDATED_ATTR, now_iso) + except Exception: + return False + return True + + +def _normalize_user(user: dict[str, Any]) -> tuple[UserSyncOutcome | None, UserIdentity | None]: + username = user.get("username") if isinstance(user.get("username"), str) else "" + if _should_skip_user(user, username): + return UserSyncOutcome("skipped"), None + user_id = user.get("id") if isinstance(user.get("id"), str) else "" + if not user_id: + return UserSyncOutcome("failed", "missing user id"), None + return None, UserIdentity(username, user_id) + + +def _load_attrs_or_outcome( + identity: UserIdentity, + user: dict[str, Any], +) -> tuple[dict[str, Any] | None, UserSyncOutcome | None]: + attrs = _load_attrs(identity.user_id, user) + if attrs is None: + return None, UserSyncOutcome("failed", "missing attributes") + return attrs, None + + +def _mailu_email_or_outcome( + identity: UserIdentity, + attrs: dict[str, Any], + user: dict[str, Any], +) -> tuple[str | None, UserSyncOutcome | None]: + mailu_email = _ensure_mailu_email( + identity.username, + attrs, + user.get("email") if isinstance(user.get("email"), str) else "", + ) + if not mailu_email: + return None, UserSyncOutcome("failed", "missing mailu email") + return mailu_email, None + + +def _wger_password_or_outcome( + identity: UserIdentity, + attrs: dict[str, Any], +) -> tuple[str | None, bool, UserSyncOutcome | None]: + password, generated = _ensure_wger_password(identity.username, attrs) + if not password: + return None, generated, UserSyncOutcome("failed", "missing wger password") + return password, generated, None + + +def _should_skip_sync(password_generated: bool, updated_at: str) -> bool: + return not password_generated and bool(updated_at) + + +def _build_sync_input(user: dict[str, Any]) -> WgerSyncInput | UserSyncOutcome: + outcome, identity = _normalize_user(user) + attrs = None + mailu_email = None + password = None + password_generated = False + + if outcome is None and identity is not None: + attrs, outcome = _load_attrs_or_outcome(identity, user) + if outcome is None and attrs is not None: + mailu_email, outcome = _mailu_email_or_outcome(identity, attrs, user) + if outcome is None and mailu_email: + password, password_generated, outcome = _wger_password_or_outcome(identity, attrs) + + if outcome: + return outcome + if identity is None: + return UserSyncOutcome("failed", "missing identity") + if attrs is None: + return UserSyncOutcome("failed", "missing attributes") + if not mailu_email: + return UserSyncOutcome("failed", "missing mailu email") + if not password: + return UserSyncOutcome("failed", "missing wger password") + + updated_at = _extract_attr(attrs, WGER_PASSWORD_UPDATED_ATTR) + return WgerSyncInput( + username=identity.username, + mailu_email=mailu_email, + password=password, + password_generated=password_generated, + updated_at=updated_at, + ) + + class WgerService: def __init__(self) -> None: self._executor = PodExecutor( @@ -196,5 +409,53 @@ class WgerService: output = (result.stdout or result.stderr).strip() return {"status": "ok", "detail": output} + def _sync_user_entry(self, user: dict[str, Any]) -> UserSyncOutcome: + prepared = _build_sync_input(user) + if isinstance(prepared, UserSyncOutcome): + return prepared + if _should_skip_sync(prepared.password_generated, prepared.updated_at): + return UserSyncOutcome("skipped") + + result = self.sync_user(prepared.username, prepared.mailu_email, prepared.password, wait=True) + result_status = result.get("status") if isinstance(result, dict) else "error" + if result_status != "ok": + return UserSyncOutcome("failed", f"sync {result_status}") + if not _set_wger_updated_at(prepared.username): + return UserSyncOutcome("failed", "failed to set updated_at") + + return UserSyncOutcome("synced") + + def sync_users(self) -> dict[str, Any]: + if not keycloak_admin.ready(): + return {"status": "error", "detail": "keycloak admin not configured"} + if not settings.wger_namespace: + raise RuntimeError("wger sync not configured") + + counters = WgerSyncCounters() + users = keycloak_admin.iter_users(page_size=200, brief=False) + for user in users: + outcome = self._sync_user_entry(user) + if outcome.status == "synced": + counters.processed += 1 + counters.synced += 1 + elif outcome.status == "skipped": + counters.skipped += 1 + else: + counters.failures += 1 + + summary = counters.summary() + logger.info( + "wger user sync finished", + extra={ + "event": "wger_user_sync", + "status": counters.status(), + "processed": summary.processed, + "synced": summary.synced, + "skipped": summary.skipped, + "failures": summary.failures, + }, + ) + return {"status": counters.status(), "summary": summary} + wger = WgerService() diff --git a/ariadne/settings.py b/ariadne/settings.py index e9a63c2..814f4a1 100644 --- a/ariadne/settings.py +++ b/ariadne/settings.py @@ -57,6 +57,7 @@ class Settings: mailu_domain: str mailu_sync_url: str + mailu_event_min_interval_sec: float mailu_sync_wait_timeout_sec: float mailu_mailbox_wait_timeout_sec: float mailu_db_host: str @@ -65,6 +66,9 @@ class Settings: mailu_db_user: str mailu_db_password: str mailu_host: str + mailu_default_quota: int + mailu_system_users: list[str] + mailu_system_password: str nextcloud_namespace: str nextcloud_pod_label: str @@ -179,7 +183,9 @@ class Settings: nextcloud_cron: str nextcloud_maintenance_cron: str vaultwarden_sync_cron: str + wger_user_sync_cron: str wger_admin_cron: str + firefly_user_sync_cron: str firefly_cron: str pod_cleaner_cron: str opensearch_prune_cron: str @@ -200,208 +206,298 @@ class Settings: metrics_path: str @classmethod - def from_env(cls) -> "Settings": + def _keycloak_config(cls) -> dict[str, Any]: keycloak_url = _env("KEYCLOAK_URL", "https://sso.bstein.dev").rstrip("/") keycloak_realm = _env("KEYCLOAK_REALM", "atlas") keycloak_client_id = _env("KEYCLOAK_CLIENT_ID", "bstein-dev-home") keycloak_issuer = _env("KEYCLOAK_ISSUER", f"{keycloak_url}/realms/{keycloak_realm}").rstrip("/") keycloak_jwks_url = _env("KEYCLOAK_JWKS_URL", f"{keycloak_issuer}/protocol/openid-connect/certs").rstrip("/") + return { + "keycloak_url": keycloak_url, + "keycloak_realm": keycloak_realm, + "keycloak_client_id": keycloak_client_id, + "keycloak_issuer": keycloak_issuer, + "keycloak_jwks_url": keycloak_jwks_url, + "keycloak_admin_url": _env("KEYCLOAK_ADMIN_URL", keycloak_url).rstrip("/"), + "keycloak_admin_realm": _env("KEYCLOAK_ADMIN_REALM", keycloak_realm), + "keycloak_admin_client_id": _env("KEYCLOAK_ADMIN_CLIENT_ID", ""), + "keycloak_admin_client_secret": _env("KEYCLOAK_ADMIN_CLIENT_SECRET", ""), + } - admin_users = [u for u in (_env("PORTAL_ADMIN_USERS", "bstein")).split(",") if u.strip()] - admin_groups = [g for g in (_env("PORTAL_ADMIN_GROUPS", "admin")).split(",") if g.strip()] - allowed_groups = [g for g in (_env("ACCOUNT_ALLOWED_GROUPS", "dev,admin")).split(",") if g.strip()] - flag_groups = [g for g in (_env("ALLOWED_FLAG_GROUPS", "demo,test")).split(",") if g.strip()] - default_groups = [g for g in (_env("DEFAULT_USER_GROUPS", "dev")).split(",") if g.strip()] + @classmethod + def _portal_group_config(cls) -> dict[str, Any]: + return { + "portal_admin_users": [u for u in (_env("PORTAL_ADMIN_USERS", "bstein")).split(",") if u.strip()], + "portal_admin_groups": [g for g in (_env("PORTAL_ADMIN_GROUPS", "admin")).split(",") if g.strip()], + "account_allowed_groups": [ + g for g in (_env("ACCOUNT_ALLOWED_GROUPS", "dev,admin")).split(",") if g.strip() + ], + "allowed_flag_groups": [g for g in (_env("ALLOWED_FLAG_GROUPS", "demo,test")).split(",") if g.strip()], + "default_user_groups": [g for g in (_env("DEFAULT_USER_GROUPS", "dev")).split(",") if g.strip()], + } - mailu_db_port = _env_int("MAILU_DB_PORT", 5432) + @classmethod + def _mailu_config(cls) -> dict[str, Any]: mailu_domain = _env("MAILU_DOMAIN", "bstein.dev") - smtp_port = _env_int("SMTP_PORT", 25) + return { + "mailu_domain": mailu_domain, + "mailu_sync_url": _env( + "MAILU_SYNC_URL", + "http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events", + ).rstrip("/"), + "mailu_event_min_interval_sec": _env_float("MAILU_EVENT_MIN_INTERVAL_SEC", 10.0), + "mailu_sync_wait_timeout_sec": _env_float("MAILU_SYNC_WAIT_TIMEOUT_SEC", 60.0), + "mailu_mailbox_wait_timeout_sec": _env_float("MAILU_MAILBOX_WAIT_TIMEOUT_SEC", 60.0), + "mailu_db_host": _env("MAILU_DB_HOST", "postgres-service.postgres.svc.cluster.local"), + "mailu_db_port": _env_int("MAILU_DB_PORT", 5432), + "mailu_db_name": _env("MAILU_DB_NAME", "mailu"), + "mailu_db_user": _env("MAILU_DB_USER", "mailu"), + "mailu_db_password": _env("MAILU_DB_PASSWORD", ""), + "mailu_host": _env("MAILU_HOST", f"mail.{mailu_domain}"), + "mailu_default_quota": _env_int("MAILU_DEFAULT_QUOTA", 20000000000), + "mailu_system_users": [u for u in _env("MAILU_SYSTEM_USERS", "").split(",") if u.strip()], + "mailu_system_password": _env("MAILU_SYSTEM_PASSWORD", ""), + } + + @classmethod + def _smtp_config(cls, mailu_domain: str) -> dict[str, Any]: + return { + "smtp_host": _env("SMTP_HOST", ""), + "smtp_port": _env_int("SMTP_PORT", 25), + "smtp_username": _env("SMTP_USERNAME", ""), + "smtp_password": _env("SMTP_PASSWORD", ""), + "smtp_starttls": _env_bool("SMTP_STARTTLS", "false"), + "smtp_use_tls": _env_bool("SMTP_USE_TLS", "false"), + "smtp_from": _env("SMTP_FROM", f"postmaster@{mailu_domain}"), + "smtp_timeout_sec": _env_float("SMTP_TIMEOUT_SEC", 10.0), + "welcome_email_enabled": _env_bool("WELCOME_EMAIL_ENABLED", "true"), + } + + @classmethod + def _nextcloud_config(cls) -> dict[str, Any]: + return { + "nextcloud_namespace": _env("NEXTCLOUD_NAMESPACE", "nextcloud"), + "nextcloud_pod_label": _env("NEXTCLOUD_POD_LABEL", "app=nextcloud"), + "nextcloud_container": _env("NEXTCLOUD_CONTAINER", "nextcloud"), + "nextcloud_exec_timeout_sec": _env_float("NEXTCLOUD_EXEC_TIMEOUT_SEC", 120.0), + "nextcloud_db_host": _env("NEXTCLOUD_DB_HOST", "postgres-service.postgres.svc.cluster.local"), + "nextcloud_db_port": _env_int("NEXTCLOUD_DB_PORT", 5432), + "nextcloud_db_name": _env("NEXTCLOUD_DB_NAME", "nextcloud"), + "nextcloud_db_user": _env("NEXTCLOUD_DB_USER", "nextcloud"), + "nextcloud_db_password": _env("NEXTCLOUD_DB_PASSWORD", ""), + "nextcloud_url": _env("NEXTCLOUD_URL", "https://cloud.bstein.dev").rstrip("/"), + "nextcloud_admin_user": _env("NEXTCLOUD_ADMIN_USER", ""), + "nextcloud_admin_password": _env("NEXTCLOUD_ADMIN_PASSWORD", ""), + } + + @classmethod + def _wger_config(cls) -> dict[str, Any]: + return { + "wger_namespace": _env("WGER_NAMESPACE", "health"), + "wger_user_sync_wait_timeout_sec": _env_float("WGER_USER_SYNC_WAIT_TIMEOUT_SEC", 60.0), + "wger_pod_label": _env("WGER_POD_LABEL", "app=wger"), + "wger_container": _env("WGER_CONTAINER", "wger"), + "wger_admin_username": _env("WGER_ADMIN_USERNAME", ""), + "wger_admin_password": _env("WGER_ADMIN_PASSWORD", ""), + "wger_admin_email": _env("WGER_ADMIN_EMAIL", ""), + } + + @classmethod + def _firefly_config(cls) -> dict[str, Any]: + return { + "firefly_namespace": _env("FIREFLY_NAMESPACE", "finance"), + "firefly_user_sync_wait_timeout_sec": _env_float("FIREFLY_USER_SYNC_WAIT_TIMEOUT_SEC", 90.0), + "firefly_pod_label": _env("FIREFLY_POD_LABEL", "app=firefly"), + "firefly_container": _env("FIREFLY_CONTAINER", "firefly"), + "firefly_cron_base_url": _env( + "FIREFLY_CRON_BASE_URL", + "http://firefly.finance.svc.cluster.local/api/v1/cron", + ), + "firefly_cron_token": _env("FIREFLY_CRON_TOKEN", ""), + "firefly_cron_timeout_sec": _env_float("FIREFLY_CRON_TIMEOUT_SEC", 30.0), + } + + @classmethod + def _vault_config(cls) -> dict[str, Any]: + return { + "vault_namespace": _env("VAULT_NAMESPACE", "vault"), + "vault_addr": _env("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200").rstrip("/"), + "vault_token": _env("VAULT_TOKEN", ""), + "vault_k8s_role": _env("VAULT_K8S_ROLE", "vault"), + "vault_k8s_role_ttl": _env("VAULT_K8S_ROLE_TTL", "1h"), + "vault_k8s_token_reviewer_jwt": _env("VAULT_K8S_TOKEN_REVIEWER_JWT", ""), + "vault_k8s_token_reviewer_jwt_file": _env("VAULT_K8S_TOKEN_REVIEWER_JWT_FILE", ""), + "vault_oidc_discovery_url": _env("VAULT_OIDC_DISCOVERY_URL", ""), + "vault_oidc_client_id": _env("VAULT_OIDC_CLIENT_ID", ""), + "vault_oidc_client_secret": _env("VAULT_OIDC_CLIENT_SECRET", ""), + "vault_oidc_default_role": _env("VAULT_OIDC_DEFAULT_ROLE", "admin"), + "vault_oidc_scopes": _env("VAULT_OIDC_SCOPES", "openid profile email groups"), + "vault_oidc_user_claim": _env("VAULT_OIDC_USER_CLAIM", "preferred_username"), + "vault_oidc_groups_claim": _env("VAULT_OIDC_GROUPS_CLAIM", "groups"), + "vault_oidc_token_policies": _env("VAULT_OIDC_TOKEN_POLICIES", ""), + "vault_oidc_admin_group": _env("VAULT_OIDC_ADMIN_GROUP", "admin"), + "vault_oidc_admin_policies": _env("VAULT_OIDC_ADMIN_POLICIES", "default,vault-admin"), + "vault_oidc_dev_group": _env("VAULT_OIDC_DEV_GROUP", "dev"), + "vault_oidc_dev_policies": _env("VAULT_OIDC_DEV_POLICIES", "default,dev-kv"), + "vault_oidc_user_group": _env("VAULT_OIDC_USER_GROUP", ""), + "vault_oidc_user_policies": _env("VAULT_OIDC_USER_POLICIES", ""), + "vault_oidc_redirect_uris": _env( + "VAULT_OIDC_REDIRECT_URIS", + "https://secret.bstein.dev/ui/vault/auth/oidc/oidc/callback", + ), + "vault_oidc_bound_audiences": _env("VAULT_OIDC_BOUND_AUDIENCES", ""), + "vault_oidc_bound_claims_type": _env("VAULT_OIDC_BOUND_CLAIMS_TYPE", "string"), + } + + @classmethod + def _comms_config(cls) -> dict[str, Any]: + return { + "comms_namespace": _env("COMMS_NAMESPACE", "comms"), + "comms_synapse_base": _env( + "COMMS_SYNAPSE_BASE", + "http://othrys-synapse-matrix-synapse:8008", + ).rstrip("/"), + "comms_auth_base": _env( + "COMMS_AUTH_BASE", + "http://matrix-authentication-service:8080", + ).rstrip("/"), + "comms_mas_admin_api_base": _env( + "COMMS_MAS_ADMIN_API_BASE", + "http://matrix-authentication-service:8081/api/admin/v1", + ).rstrip("/"), + "comms_mas_token_url": _env( + "COMMS_MAS_TOKEN_URL", + "http://matrix-authentication-service:8080/oauth2/token", + ), + "comms_mas_admin_client_id": _env("COMMS_MAS_ADMIN_CLIENT_ID", "01KDXMVQBQ5JNY6SEJPZW6Z8BM"), + "comms_mas_admin_client_secret": _env("COMMS_MAS_ADMIN_CLIENT_SECRET", ""), + "comms_server_name": _env("COMMS_SERVER_NAME", "live.bstein.dev"), + "comms_room_alias": _env("COMMS_ROOM_ALIAS", "#othrys:live.bstein.dev"), + "comms_room_name": _env("COMMS_ROOM_NAME", "Othrys"), + "comms_pin_message": _env( + "COMMS_PIN_MESSAGE", + "Invite guests: share https://live.bstein.dev/#/room/#othrys:live.bstein.dev?action=join and choose 'Continue' -> 'Join as guest'.", + ), + "comms_seeder_user": _env("COMMS_SEEDER_USER", "othrys-seeder"), + "comms_seeder_password": _env("COMMS_SEEDER_PASSWORD", ""), + "comms_bot_user": _env("COMMS_BOT_USER", "atlasbot"), + "comms_bot_password": _env("COMMS_BOT_PASSWORD", ""), + "comms_synapse_db_host": _env( + "COMMS_SYNAPSE_DB_HOST", + "postgres-service.postgres.svc.cluster.local", + ), + "comms_synapse_db_port": _env_int("COMMS_SYNAPSE_DB_PORT", 5432), + "comms_synapse_db_name": _env("COMMS_SYNAPSE_DB_NAME", "synapse"), + "comms_synapse_db_user": _env("COMMS_SYNAPSE_DB_USER", "synapse"), + "comms_synapse_db_password": _env("COMMS_SYNAPSE_DB_PASSWORD", ""), + "comms_timeout_sec": _env_float("COMMS_TIMEOUT_SEC", 30.0), + "comms_guest_stale_days": _env_int("COMMS_GUEST_STALE_DAYS", 14), + } + + @classmethod + def _image_sweeper_config(cls) -> dict[str, Any]: + return { + "image_sweeper_namespace": _env("IMAGE_SWEEPER_NAMESPACE", "maintenance"), + "image_sweeper_service_account": _env("IMAGE_SWEEPER_SERVICE_ACCOUNT", "node-image-sweeper"), + "image_sweeper_job_ttl_sec": _env_int("IMAGE_SWEEPER_JOB_TTL_SEC", 3600), + "image_sweeper_wait_timeout_sec": _env_float("IMAGE_SWEEPER_WAIT_TIMEOUT_SEC", 1200.0), + } + + @classmethod + def _vaultwarden_config(cls) -> dict[str, Any]: + return { + "vaultwarden_namespace": _env("VAULTWARDEN_NAMESPACE", "vaultwarden"), + "vaultwarden_pod_label": _env("VAULTWARDEN_POD_LABEL", "app=vaultwarden"), + "vaultwarden_pod_port": _env_int("VAULTWARDEN_POD_PORT", 80), + "vaultwarden_service_host": _env( + "VAULTWARDEN_SERVICE_HOST", + "vaultwarden-service.vaultwarden.svc.cluster.local", + ), + "vaultwarden_admin_secret_name": _env("VAULTWARDEN_ADMIN_SECRET_NAME", "vaultwarden-admin"), + "vaultwarden_admin_secret_key": _env("VAULTWARDEN_ADMIN_SECRET_KEY", "ADMIN_TOKEN"), + "vaultwarden_admin_session_ttl_sec": _env_float("VAULTWARDEN_ADMIN_SESSION_TTL_SEC", 300.0), + "vaultwarden_admin_rate_limit_backoff_sec": _env_float("VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC", 600.0), + "vaultwarden_retry_cooldown_sec": _env_float("VAULTWARDEN_RETRY_COOLDOWN_SEC", 1800.0), + "vaultwarden_failure_bailout": _env_int("VAULTWARDEN_FAILURE_BAILOUT", 2), + } + + @classmethod + def _schedule_config(cls) -> dict[str, Any]: + return { + "mailu_sync_cron": _env("ARIADNE_SCHEDULE_MAILU_SYNC", "30 4 * * *"), + "nextcloud_sync_cron": _env("ARIADNE_SCHEDULE_NEXTCLOUD_SYNC", "0 5 * * *"), + "nextcloud_cron": _env("ARIADNE_SCHEDULE_NEXTCLOUD_CRON", "*/5 * * * *"), + "nextcloud_maintenance_cron": _env("ARIADNE_SCHEDULE_NEXTCLOUD_MAINTENANCE", "30 4 * * *"), + "vaultwarden_sync_cron": _env("ARIADNE_SCHEDULE_VAULTWARDEN_SYNC", "*/15 * * * *"), + "wger_user_sync_cron": _env("ARIADNE_SCHEDULE_WGER_USER_SYNC", "0 5 * * *"), + "wger_admin_cron": _env("ARIADNE_SCHEDULE_WGER_ADMIN", "15 3 * * *"), + "firefly_user_sync_cron": _env("ARIADNE_SCHEDULE_FIREFLY_USER_SYNC", "0 6 * * *"), + "firefly_cron": _env("ARIADNE_SCHEDULE_FIREFLY_CRON", "0 3 * * *"), + "pod_cleaner_cron": _env("ARIADNE_SCHEDULE_POD_CLEANER", "0 * * * *"), + "opensearch_prune_cron": _env("ARIADNE_SCHEDULE_OPENSEARCH_PRUNE", "23 3 * * *"), + "image_sweeper_cron": _env("ARIADNE_SCHEDULE_IMAGE_SWEEPER", "30 4 * * 0"), + "vault_k8s_auth_cron": _env("ARIADNE_SCHEDULE_VAULT_K8S_AUTH", "*/15 * * * *"), + "vault_oidc_cron": _env("ARIADNE_SCHEDULE_VAULT_OIDC", "*/15 * * * *"), + "comms_guest_name_cron": _env("ARIADNE_SCHEDULE_COMMS_GUEST_NAME", "*/1 * * * *"), + "comms_pin_invite_cron": _env("ARIADNE_SCHEDULE_COMMS_PIN_INVITE", "*/30 * * * *"), + "comms_reset_room_cron": _env("ARIADNE_SCHEDULE_COMMS_RESET_ROOM", "0 0 1 1 *"), + "comms_seed_room_cron": _env("ARIADNE_SCHEDULE_COMMS_SEED_ROOM", "*/10 * * * *"), + "keycloak_profile_cron": _env("ARIADNE_SCHEDULE_KEYCLOAK_PROFILE", "0 */6 * * *"), + } + + @classmethod + def _opensearch_config(cls) -> dict[str, Any]: + return { + "opensearch_url": _env( + "OPENSEARCH_URL", + "http://opensearch-master.logging.svc.cluster.local:9200", + ).rstrip("/"), + "opensearch_limit_bytes": _env_int("OPENSEARCH_LIMIT_BYTES", 1024**4), + "opensearch_index_patterns": _env("OPENSEARCH_INDEX_PATTERNS", "kube-*,journald-*"), + "opensearch_timeout_sec": _env_float("OPENSEARCH_TIMEOUT_SEC", 30.0), + } + + @classmethod + def from_env(cls) -> "Settings": + keycloak_cfg = cls._keycloak_config() + portal_cfg = cls._portal_group_config() + mailu_cfg = cls._mailu_config() + smtp_cfg = cls._smtp_config(mailu_cfg["mailu_domain"]) + nextcloud_cfg = cls._nextcloud_config() + wger_cfg = cls._wger_config() + firefly_cfg = cls._firefly_config() + vault_cfg = cls._vault_config() + comms_cfg = cls._comms_config() + image_cfg = cls._image_sweeper_config() + vaultwarden_cfg = cls._vaultwarden_config() + schedule_cfg = cls._schedule_config() + opensearch_cfg = cls._opensearch_config() return cls( app_name=_env("ARIADNE_APP_NAME", "ariadne"), bind_host=_env("ARIADNE_BIND_HOST", "0.0.0.0"), bind_port=_env_int("ARIADNE_BIND_PORT", 8080), - portal_database_url=_env("PORTAL_DATABASE_URL", ""), + portal_database_url=_env("ARIADNE_DATABASE_URL", _env("PORTAL_DATABASE_URL", "")), portal_public_base_url=_env("PORTAL_PUBLIC_BASE_URL", "https://bstein.dev").rstrip("/"), log_level=_env("ARIADNE_LOG_LEVEL", "INFO"), - keycloak_url=keycloak_url, - keycloak_realm=keycloak_realm, - keycloak_client_id=keycloak_client_id, - keycloak_issuer=keycloak_issuer, - keycloak_jwks_url=keycloak_jwks_url, - keycloak_admin_url=_env("KEYCLOAK_ADMIN_URL", keycloak_url).rstrip("/"), - keycloak_admin_realm=_env("KEYCLOAK_ADMIN_REALM", keycloak_realm), - keycloak_admin_client_id=_env("KEYCLOAK_ADMIN_CLIENT_ID", ""), - keycloak_admin_client_secret=_env("KEYCLOAK_ADMIN_CLIENT_SECRET", ""), - portal_admin_users=admin_users, - portal_admin_groups=admin_groups, - account_allowed_groups=allowed_groups, - allowed_flag_groups=flag_groups, - default_user_groups=default_groups, - mailu_domain=mailu_domain, - mailu_sync_url=_env( - "MAILU_SYNC_URL", - "http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events", - ).rstrip("/"), - mailu_sync_wait_timeout_sec=_env_float("MAILU_SYNC_WAIT_TIMEOUT_SEC", 60.0), - mailu_mailbox_wait_timeout_sec=_env_float("MAILU_MAILBOX_WAIT_TIMEOUT_SEC", 60.0), - mailu_db_host=_env("MAILU_DB_HOST", "postgres-service.postgres.svc.cluster.local"), - mailu_db_port=mailu_db_port, - mailu_db_name=_env("MAILU_DB_NAME", "mailu"), - mailu_db_user=_env("MAILU_DB_USER", "mailu"), - mailu_db_password=_env("MAILU_DB_PASSWORD", ""), - mailu_host=_env("MAILU_HOST", f"mail.{mailu_domain}"), - nextcloud_namespace=_env("NEXTCLOUD_NAMESPACE", "nextcloud"), - nextcloud_pod_label=_env("NEXTCLOUD_POD_LABEL", "app=nextcloud"), - nextcloud_container=_env("NEXTCLOUD_CONTAINER", "nextcloud"), - nextcloud_exec_timeout_sec=_env_float("NEXTCLOUD_EXEC_TIMEOUT_SEC", 120.0), - nextcloud_db_host=_env("NEXTCLOUD_DB_HOST", "postgres-service.postgres.svc.cluster.local"), - nextcloud_db_port=_env_int("NEXTCLOUD_DB_PORT", 5432), - nextcloud_db_name=_env("NEXTCLOUD_DB_NAME", "nextcloud"), - nextcloud_db_user=_env("NEXTCLOUD_DB_USER", "nextcloud"), - nextcloud_db_password=_env("NEXTCLOUD_DB_PASSWORD", ""), - nextcloud_url=_env("NEXTCLOUD_URL", "https://cloud.bstein.dev").rstrip("/"), - nextcloud_admin_user=_env("NEXTCLOUD_ADMIN_USER", ""), - nextcloud_admin_password=_env("NEXTCLOUD_ADMIN_PASSWORD", ""), - wger_namespace=_env("WGER_NAMESPACE", "health"), - wger_user_sync_wait_timeout_sec=_env_float("WGER_USER_SYNC_WAIT_TIMEOUT_SEC", 60.0), - wger_pod_label=_env("WGER_POD_LABEL", "app=wger"), - wger_container=_env("WGER_CONTAINER", "wger"), - wger_admin_username=_env("WGER_ADMIN_USERNAME", ""), - wger_admin_password=_env("WGER_ADMIN_PASSWORD", ""), - wger_admin_email=_env("WGER_ADMIN_EMAIL", ""), - firefly_namespace=_env("FIREFLY_NAMESPACE", "finance"), - firefly_user_sync_wait_timeout_sec=_env_float("FIREFLY_USER_SYNC_WAIT_TIMEOUT_SEC", 90.0), - firefly_pod_label=_env("FIREFLY_POD_LABEL", "app=firefly"), - firefly_container=_env("FIREFLY_CONTAINER", "firefly"), - firefly_cron_base_url=_env( - "FIREFLY_CRON_BASE_URL", - "http://firefly.finance.svc.cluster.local/api/v1/cron", - ), - firefly_cron_token=_env("FIREFLY_CRON_TOKEN", ""), - firefly_cron_timeout_sec=_env_float("FIREFLY_CRON_TIMEOUT_SEC", 30.0), - vault_namespace=_env("VAULT_NAMESPACE", "vault"), - vault_addr=_env("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200").rstrip("/"), - vault_token=_env("VAULT_TOKEN", ""), - vault_k8s_role=_env("VAULT_K8S_ROLE", "vault"), - vault_k8s_role_ttl=_env("VAULT_K8S_ROLE_TTL", "1h"), - vault_k8s_token_reviewer_jwt=_env("VAULT_K8S_TOKEN_REVIEWER_JWT", ""), - vault_k8s_token_reviewer_jwt_file=_env("VAULT_K8S_TOKEN_REVIEWER_JWT_FILE", ""), - vault_oidc_discovery_url=_env("VAULT_OIDC_DISCOVERY_URL", ""), - vault_oidc_client_id=_env("VAULT_OIDC_CLIENT_ID", ""), - vault_oidc_client_secret=_env("VAULT_OIDC_CLIENT_SECRET", ""), - vault_oidc_default_role=_env("VAULT_OIDC_DEFAULT_ROLE", "admin"), - vault_oidc_scopes=_env("VAULT_OIDC_SCOPES", "openid profile email groups"), - vault_oidc_user_claim=_env("VAULT_OIDC_USER_CLAIM", "preferred_username"), - vault_oidc_groups_claim=_env("VAULT_OIDC_GROUPS_CLAIM", "groups"), - vault_oidc_token_policies=_env("VAULT_OIDC_TOKEN_POLICIES", ""), - vault_oidc_admin_group=_env("VAULT_OIDC_ADMIN_GROUP", "admin"), - vault_oidc_admin_policies=_env("VAULT_OIDC_ADMIN_POLICIES", "default,vault-admin"), - vault_oidc_dev_group=_env("VAULT_OIDC_DEV_GROUP", "dev"), - vault_oidc_dev_policies=_env("VAULT_OIDC_DEV_POLICIES", "default,dev-kv"), - vault_oidc_user_group=_env("VAULT_OIDC_USER_GROUP", ""), - vault_oidc_user_policies=_env("VAULT_OIDC_USER_POLICIES", ""), - vault_oidc_redirect_uris=_env( - "VAULT_OIDC_REDIRECT_URIS", - "https://secret.bstein.dev/ui/vault/auth/oidc/oidc/callback", - ), - vault_oidc_bound_audiences=_env("VAULT_OIDC_BOUND_AUDIENCES", ""), - vault_oidc_bound_claims_type=_env("VAULT_OIDC_BOUND_CLAIMS_TYPE", "string"), - comms_namespace=_env("COMMS_NAMESPACE", "comms"), - comms_synapse_base=_env( - "COMMS_SYNAPSE_BASE", - "http://othrys-synapse-matrix-synapse:8008", - ).rstrip("/"), - comms_auth_base=_env( - "COMMS_AUTH_BASE", - "http://matrix-authentication-service:8080", - ).rstrip("/"), - comms_mas_admin_api_base=_env( - "COMMS_MAS_ADMIN_API_BASE", - "http://matrix-authentication-service:8081/api/admin/v1", - ).rstrip("/"), - comms_mas_token_url=_env( - "COMMS_MAS_TOKEN_URL", - "http://matrix-authentication-service:8080/oauth2/token", - ), - comms_mas_admin_client_id=_env("COMMS_MAS_ADMIN_CLIENT_ID", "01KDXMVQBQ5JNY6SEJPZW6Z8BM"), - comms_mas_admin_client_secret=_env("COMMS_MAS_ADMIN_CLIENT_SECRET", ""), - comms_server_name=_env("COMMS_SERVER_NAME", "live.bstein.dev"), - comms_room_alias=_env("COMMS_ROOM_ALIAS", "#othrys:live.bstein.dev"), - comms_room_name=_env("COMMS_ROOM_NAME", "Othrys"), - comms_pin_message=_env( - "COMMS_PIN_MESSAGE", - "Invite guests: share https://live.bstein.dev/#/room/#othrys:live.bstein.dev?action=join and choose 'Continue' -> 'Join as guest'.", - ), - comms_seeder_user=_env("COMMS_SEEDER_USER", "othrys-seeder"), - comms_seeder_password=_env("COMMS_SEEDER_PASSWORD", ""), - comms_bot_user=_env("COMMS_BOT_USER", "atlasbot"), - comms_bot_password=_env("COMMS_BOT_PASSWORD", ""), - comms_synapse_db_host=_env( - "COMMS_SYNAPSE_DB_HOST", - "postgres-service.postgres.svc.cluster.local", - ), - comms_synapse_db_port=_env_int("COMMS_SYNAPSE_DB_PORT", 5432), - comms_synapse_db_name=_env("COMMS_SYNAPSE_DB_NAME", "synapse"), - comms_synapse_db_user=_env("COMMS_SYNAPSE_DB_USER", "synapse"), - comms_synapse_db_password=_env("COMMS_SYNAPSE_DB_PASSWORD", ""), - comms_timeout_sec=_env_float("COMMS_TIMEOUT_SEC", 30.0), - comms_guest_stale_days=_env_int("COMMS_GUEST_STALE_DAYS", 14), - image_sweeper_namespace=_env("IMAGE_SWEEPER_NAMESPACE", "maintenance"), - image_sweeper_service_account=_env("IMAGE_SWEEPER_SERVICE_ACCOUNT", "node-image-sweeper"), - image_sweeper_job_ttl_sec=_env_int("IMAGE_SWEEPER_JOB_TTL_SEC", 3600), - image_sweeper_wait_timeout_sec=_env_float("IMAGE_SWEEPER_WAIT_TIMEOUT_SEC", 1200.0), - vaultwarden_namespace=_env("VAULTWARDEN_NAMESPACE", "vaultwarden"), - vaultwarden_pod_label=_env("VAULTWARDEN_POD_LABEL", "app=vaultwarden"), - vaultwarden_pod_port=_env_int("VAULTWARDEN_POD_PORT", 80), - vaultwarden_service_host=_env( - "VAULTWARDEN_SERVICE_HOST", - "vaultwarden-service.vaultwarden.svc.cluster.local", - ), - vaultwarden_admin_secret_name=_env("VAULTWARDEN_ADMIN_SECRET_NAME", "vaultwarden-admin"), - vaultwarden_admin_secret_key=_env("VAULTWARDEN_ADMIN_SECRET_KEY", "ADMIN_TOKEN"), - vaultwarden_admin_session_ttl_sec=_env_float("VAULTWARDEN_ADMIN_SESSION_TTL_SEC", 300.0), - vaultwarden_admin_rate_limit_backoff_sec=_env_float("VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC", 600.0), - vaultwarden_retry_cooldown_sec=_env_float("VAULTWARDEN_RETRY_COOLDOWN_SEC", 1800.0), - vaultwarden_failure_bailout=_env_int("VAULTWARDEN_FAILURE_BAILOUT", 2), - smtp_host=_env("SMTP_HOST", ""), - smtp_port=smtp_port, - smtp_username=_env("SMTP_USERNAME", ""), - smtp_password=_env("SMTP_PASSWORD", ""), - smtp_starttls=_env_bool("SMTP_STARTTLS", "false"), - smtp_use_tls=_env_bool("SMTP_USE_TLS", "false"), - smtp_from=_env("SMTP_FROM", f"postmaster@{mailu_domain}"), - smtp_timeout_sec=_env_float("SMTP_TIMEOUT_SEC", 10.0), - welcome_email_enabled=_env_bool("WELCOME_EMAIL_ENABLED", "true"), provision_poll_interval_sec=_env_float("ARIADNE_PROVISION_POLL_INTERVAL_SEC", 5.0), provision_retry_cooldown_sec=_env_float("ARIADNE_PROVISION_RETRY_COOLDOWN_SEC", 30.0), schedule_tick_sec=_env_float("ARIADNE_SCHEDULE_TICK_SEC", 5.0), k8s_api_timeout_sec=_env_float("K8S_API_TIMEOUT_SEC", 5.0), - mailu_sync_cron=_env("ARIADNE_SCHEDULE_MAILU_SYNC", "30 4 * * *"), - nextcloud_sync_cron=_env("ARIADNE_SCHEDULE_NEXTCLOUD_SYNC", "0 5 * * *"), - nextcloud_cron=_env("ARIADNE_SCHEDULE_NEXTCLOUD_CRON", "*/5 * * * *"), - nextcloud_maintenance_cron=_env("ARIADNE_SCHEDULE_NEXTCLOUD_MAINTENANCE", "30 4 * * *"), - vaultwarden_sync_cron=_env("ARIADNE_SCHEDULE_VAULTWARDEN_SYNC", "*/15 * * * *"), - wger_admin_cron=_env("ARIADNE_SCHEDULE_WGER_ADMIN", "15 3 * * *"), - firefly_cron=_env("ARIADNE_SCHEDULE_FIREFLY_CRON", "0 3 * * *"), - pod_cleaner_cron=_env("ARIADNE_SCHEDULE_POD_CLEANER", "0 * * * *"), - opensearch_prune_cron=_env("ARIADNE_SCHEDULE_OPENSEARCH_PRUNE", "23 3 * * *"), - image_sweeper_cron=_env("ARIADNE_SCHEDULE_IMAGE_SWEEPER", "30 4 * * 0"), - vault_k8s_auth_cron=_env("ARIADNE_SCHEDULE_VAULT_K8S_AUTH", "*/15 * * * *"), - vault_oidc_cron=_env("ARIADNE_SCHEDULE_VAULT_OIDC", "*/15 * * * *"), - comms_guest_name_cron=_env("ARIADNE_SCHEDULE_COMMS_GUEST_NAME", "*/1 * * * *"), - comms_pin_invite_cron=_env("ARIADNE_SCHEDULE_COMMS_PIN_INVITE", "*/30 * * * *"), - comms_reset_room_cron=_env("ARIADNE_SCHEDULE_COMMS_RESET_ROOM", "0 0 1 1 *"), - comms_seed_room_cron=_env("ARIADNE_SCHEDULE_COMMS_SEED_ROOM", "*/10 * * * *"), - keycloak_profile_cron=_env("ARIADNE_SCHEDULE_KEYCLOAK_PROFILE", "0 */6 * * *"), - opensearch_url=_env( - "OPENSEARCH_URL", - "http://opensearch-master.logging.svc.cluster.local:9200", - ).rstrip("/"), - opensearch_limit_bytes=_env_int("OPENSEARCH_LIMIT_BYTES", 1024**4), - opensearch_index_patterns=_env("OPENSEARCH_INDEX_PATTERNS", "kube-*,journald-*"), - opensearch_timeout_sec=_env_float("OPENSEARCH_TIMEOUT_SEC", 30.0), metrics_path=_env("METRICS_PATH", "/metrics"), + **keycloak_cfg, + **portal_cfg, + **mailu_cfg, + **smtp_cfg, + **nextcloud_cfg, + **wger_cfg, + **firefly_cfg, + **vault_cfg, + **comms_cfg, + **image_cfg, + **vaultwarden_cfg, + **schedule_cfg, + **opensearch_cfg, ) diff --git a/ariadne/utils/errors.py b/ariadne/utils/errors.py index 9138dd7..1aa828e 100644 --- a/ariadne/utils/errors.py +++ b/ariadne/utils/errors.py @@ -3,31 +3,47 @@ from __future__ import annotations import httpx -def safe_error_detail(exc: Exception, fallback: str) -> str: - if isinstance(exc, RuntimeError): - msg = str(exc).strip() - if msg: - return msg - if isinstance(exc, httpx.HTTPStatusError): - detail = f"http {exc.response.status_code}" - try: - payload = exc.response.json() - msg: str | None = None - if isinstance(payload, dict): - raw = payload.get("errorMessage") or payload.get("error") or payload.get("message") - if isinstance(raw, str) and raw.strip(): - msg = raw.strip() - elif isinstance(payload, str) and payload.strip(): - msg = payload.strip() - if msg: - msg = " ".join(msg.split()) - detail = f"{detail}: {msg[:200]}" - except Exception: - text = (exc.response.text or "").strip() - if text: - text = " ".join(text.split()) - detail = f"{detail}: {text[:200]}" +def _runtime_error_detail(exc: Exception) -> str | None: + if not isinstance(exc, RuntimeError): + return None + msg = str(exc).strip() + return msg or None + + +def _http_message_from_payload(payload: object) -> str | None: + if isinstance(payload, dict): + raw = payload.get("errorMessage") or payload.get("error") or payload.get("message") + if isinstance(raw, str) and raw.strip(): + return raw.strip() + if isinstance(payload, str) and payload.strip(): + return payload.strip() + return None + + +def _http_response_message(response: httpx.Response) -> str | None: + try: + payload = response.json() + except Exception: + text = (response.text or "").strip() + return text or None + return _http_message_from_payload(payload) + + +def _http_error_detail(exc: httpx.HTTPStatusError) -> str: + detail = f"http {exc.response.status_code}" + msg = _http_response_message(exc.response) + if not msg: return detail + msg = " ".join(msg.split()) + return f"{detail}: {msg[:200]}" + + +def safe_error_detail(exc: Exception, fallback: str) -> str: + runtime_detail = _runtime_error_detail(exc) + if runtime_detail: + return runtime_detail + if isinstance(exc, httpx.HTTPStatusError): + return _http_error_detail(exc) if isinstance(exc, httpx.TimeoutException): return "timeout" return fallback diff --git a/ariadne/utils/http.py b/ariadne/utils/http.py index 05449cf..1535588 100644 --- a/ariadne/utils/http.py +++ b/ariadne/utils/http.py @@ -3,12 +3,15 @@ from __future__ import annotations from fastapi import Request +_BEARER_PARTS = 2 + + def extract_bearer_token(request: Request) -> str | None: header = request.headers.get("Authorization", "") if not header: return None parts = header.split(None, 1) - if len(parts) != 2: + if len(parts) != _BEARER_PARTS: return None scheme, token = parts[0].lower(), parts[1].strip() if scheme != "bearer" or not token: diff --git a/ariadne/utils/logging.py b/ariadne/utils/logging.py index 5eceb2c..80a4898 100644 --- a/ariadne/utils/logging.py +++ b/ariadne/utils/logging.py @@ -49,7 +49,7 @@ class JsonFormatter(logging.Formatter): "logger": record.name, "message": record.getMessage(), } - task_name = getattr(record, "taskName", None) + task_name = getattr(record, "taskName", None) or getattr(record, "task", None) if task_name: payload["taskName"] = task_name diff --git a/ariadne/utils/name_generator.py b/ariadne/utils/name_generator.py new file mode 100644 index 0000000..44738fd --- /dev/null +++ b/ariadne/utils/name_generator.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import coolname + + +@dataclass(frozen=True) +class NameGenerator: + words: int = 2 + separator: str = "-" + max_attempts: int = 30 + + def generate(self) -> str: + parts = coolname.generate(self.words) + cleaned = [part.strip().lower() for part in parts if isinstance(part, str) and part.strip()] + return self.separator.join(cleaned) + + def unique(self, existing: set[str]) -> str | None: + for _ in range(self.max_attempts): + candidate = self.generate() + if candidate and candidate not in existing: + existing.add(candidate) + return candidate + return None diff --git a/requirements-dev.txt b/requirements-dev.txt index d288470..4dd4f74 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ pytest==8.3.5 pytest-mock==3.14.0 slipcover==1.0.17 +ruff==0.14.13 diff --git a/requirements.txt b/requirements.txt index 1170cf5..1e78764 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,5 @@ psycopg-pool==3.2.6 croniter==2.0.7 prometheus-client==0.21.1 PyYAML==6.0.2 +coolname==2.2.0 +passlib[bcrypt]==1.7.4 diff --git a/tests/test_app.py b/tests/test_app.py index d1927c1..91174bc 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -107,6 +107,22 @@ def test_metrics_endpoint(monkeypatch) -> None: assert resp.status_code == 200 +def test_mailu_event_endpoint(monkeypatch) -> None: + ctx = AuthContext(username="", email="", groups=[], claims={}) + client = _client(monkeypatch, ctx) + + class DummyEvents: + def handle_event(self, payload): + assert payload == {"wait": False} + return 202, {"status": "accepted"} + + monkeypatch.setattr(app_module, "mailu_events", DummyEvents()) + + resp = client.post("/events", json={"wait": False}) + assert resp.status_code == 202 + assert resp.json()["status"] == "accepted" + + def test_list_access_requests(monkeypatch) -> None: ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) client = _client(monkeypatch, ctx) @@ -378,6 +394,7 @@ def test_rotate_mailu_password(monkeypatch) -> None: monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: None) + monkeypatch.setattr(app_module.mailu, "ready", lambda: True) monkeypatch.setattr(app_module.mailu, "sync", lambda *args, **kwargs: None) monkeypatch.setattr(app_module.nextcloud, "sync_mail", lambda *args, **kwargs: {"status": "ok"}) diff --git a/tests/test_mailu_events.py b/tests/test_mailu_events.py new file mode 100644 index 0000000..9393052 --- /dev/null +++ b/tests/test_mailu_events.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from ariadne.services.mailu_events import MailuEventRunner + + +def _instant_thread_factory(target=None, args=(), daemon=None): + class DummyThread: + def start(self) -> None: + if target: + target(*args) + + return DummyThread() + + +def test_mailu_event_wait_success() -> None: + calls = [] + + def runner(reason: str, force: bool): + calls.append((reason, force)) + return "ok", "" + + events = MailuEventRunner( + min_interval_sec=0.0, + wait_timeout_sec=0.1, + runner=runner, + thread_factory=_instant_thread_factory, + ) + status, payload = events.handle_event({"wait": True}) + assert status == 200 + assert payload["status"] == "ok" + assert calls + + +def test_mailu_event_debounce() -> None: + def runner(_reason: str, _force: bool): + return "ok", "" + + events = MailuEventRunner( + min_interval_sec=60.0, + wait_timeout_sec=0.1, + runner=runner, + thread_factory=_instant_thread_factory, + ) + status, payload = events.handle_event({}) + assert status == 202 + assert payload["status"] == "accepted" + + status, payload = events.handle_event({}) + assert status == 202 + assert payload["status"] == "skipped" + + status, payload = events.handle_event({"force": True}) + assert status == 202 + assert payload["status"] == "accepted" diff --git a/tests/test_provisioning.py b/tests/test_provisioning.py index cb340a6..cf089ad 100644 --- a/tests/test_provisioning.py +++ b/tests/test_provisioning.py @@ -99,6 +99,12 @@ class DummyAdmin: self.groups.append(group_id) +def _patch_mailu_ready(monkeypatch, settings, value=None) -> None: + if value is None: + value = bool(getattr(settings, "mailu_sync_url", "")) + monkeypatch.setattr(prov.mailu, "ready", lambda: value) + + def test_provisioning_filters_flag_groups(monkeypatch) -> None: dummy_settings = types.SimpleNamespace( mailu_domain="bstein.dev", @@ -113,6 +119,7 @@ def test_provisioning_filters_flag_groups(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) admin = DummyAdmin() monkeypatch.setattr(prov, "keycloak_admin", admin) @@ -160,6 +167,7 @@ def test_provisioning_creates_user_and_password(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class Admin(DummyAdmin): def __init__(self): @@ -279,6 +287,7 @@ def test_provisioning_cooldown_short_circuit(monkeypatch) -> None: provision_poll_interval_sec=1.0, ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: True) row = { @@ -312,6 +321,7 @@ def test_provisioning_mailu_sync_disabled(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class Admin(DummyAdmin): def __init__(self): @@ -379,6 +389,7 @@ def test_provisioning_sets_missing_email(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class Admin(DummyAdmin): def __init__(self): @@ -437,6 +448,7 @@ def test_provisioning_mailbox_not_ready(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) admin = DummyAdmin() monkeypatch.setattr(prov, "keycloak_admin", admin) @@ -480,6 +492,7 @@ def test_provisioning_sync_errors(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) admin = DummyAdmin() monkeypatch.setattr(prov, "keycloak_admin", admin) @@ -512,6 +525,7 @@ def test_provisioning_sync_errors(monkeypatch) -> None: def test_provisioning_run_loop_processes_candidates(monkeypatch) -> None: dummy_settings = types.SimpleNamespace(provision_poll_interval_sec=0.0) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: True) counts = [{"status": "pending", "count": 2}, {"status": "approved", "count": 1}] @@ -544,6 +558,7 @@ def test_provisioning_run_loop_processes_candidates(monkeypatch) -> None: def test_provisioning_run_loop_waits_for_admin(monkeypatch) -> None: dummy_settings = types.SimpleNamespace(provision_poll_interval_sec=0.0) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: False) class DB: @@ -578,6 +593,7 @@ def test_provisioning_missing_verified_email(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class Admin(DummyAdmin): def find_user(self, username): @@ -619,6 +635,7 @@ def test_provisioning_initial_password_missing(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) admin = DummyAdmin() monkeypatch.setattr(prov, "keycloak_admin", admin) @@ -660,6 +677,7 @@ def test_provisioning_group_and_mailu_errors(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class Admin(DummyAdmin): def get_group_id(self, group_name: str): @@ -742,6 +760,7 @@ def test_provisioning_send_welcome_email_variants(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) manager._send_welcome_email("REQ", "alice", "alice@example.com") @@ -750,6 +769,7 @@ def test_provisioning_send_welcome_email_variants(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) manager._send_welcome_email("REQ", "alice", "") class DB(DummyDB): @@ -862,6 +882,7 @@ def test_provisioning_locked_returns_accounts_building(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) db = DummyDB({"username": "alice"}, locked=False) manager = prov.ProvisioningManager(db, DummyStorage()) @@ -883,6 +904,7 @@ def test_provisioning_missing_row_returns_unknown(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) db = DummyDB(None) manager = prov.ProvisioningManager(db, DummyStorage()) @@ -904,6 +926,7 @@ def test_provisioning_denied_status_returns_denied(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) row = { "username": "alice", @@ -935,6 +958,7 @@ def test_provisioning_respects_retry_cooldown(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) row = { "username": "alice", @@ -966,6 +990,7 @@ def test_provisioning_updates_existing_user_attrs(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class Admin(DummyAdmin): def __init__(self): @@ -1035,6 +1060,7 @@ def test_provisioning_mailu_sync_failure(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) monkeypatch.setattr(prov.mailu, "sync", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail"))) monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: False) @@ -1071,6 +1097,7 @@ def test_provisioning_nextcloud_sync_error(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda *args, **kwargs: {"status": "error"}) @@ -1110,6 +1137,7 @@ def test_provisioning_wger_firefly_errors(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "error"}) @@ -1148,6 +1176,7 @@ def test_provisioning_start_event_failure(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) @@ -1189,6 +1218,7 @@ def test_provisioning_missing_verified_email(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class Admin(DummyAdmin): def find_user(self, username): @@ -1225,6 +1255,7 @@ def test_provisioning_email_conflict(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class Admin(DummyAdmin): def find_user(self, username): @@ -1264,6 +1295,7 @@ def test_provisioning_missing_contact_email(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class Admin(DummyAdmin): def find_user(self, username): @@ -1300,6 +1332,7 @@ def test_provisioning_user_id_missing(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class Admin(DummyAdmin): def find_user(self, username): @@ -1336,6 +1369,7 @@ def test_provisioning_initial_password_revealed(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) @@ -1372,6 +1406,7 @@ def test_provisioning_vaultwarden_attribute_failure(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class Admin(DummyAdmin): def set_user_attribute(self, username, key, value): @@ -1413,6 +1448,7 @@ def test_provisioning_complete_event_failure(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) @@ -1455,6 +1491,7 @@ def test_provisioning_pending_event_failure(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) @@ -1489,6 +1526,7 @@ def test_send_welcome_email_already_sent(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class DB(DummyDB): def fetchone(self, query, params=None): @@ -1512,6 +1550,7 @@ def test_send_welcome_email_marks_sent(monkeypatch) -> None: portal_public_base_url="https://bstein.dev", ) monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) class DB(DummyDB): def fetchone(self, query, params=None): diff --git a/tests/test_services.py b/tests/test_services.py index da0985c..da4b711 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -29,24 +29,6 @@ class DummyExecutor: ) -class DummyClient: - def __init__(self): - self.url = "" - self.payload = None - self.status_code = 200 - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def post(self, url, json=None): - self.url = url - self.payload = json - return types.SimpleNamespace(status_code=self.status_code) - - class DummyResponse: def __init__(self, status_code=200, text=""): self.status_code = status_code @@ -156,6 +138,53 @@ def test_wger_ensure_admin_exec(monkeypatch) -> None: assert calls[0]["WGER_ADMIN_USERNAME"] == "admin" +def test_wger_sync_users(monkeypatch) -> None: + dummy = types.SimpleNamespace( + wger_namespace="health", + wger_user_sync_wait_timeout_sec=60.0, + wger_pod_label="app=wger", + wger_container="wger", + wger_admin_username="admin", + wger_admin_password="pw", + wger_admin_email="admin@bstein.dev", + mailu_domain="bstein.dev", + ) + monkeypatch.setattr("ariadne.services.wger.settings", dummy) + + calls: list[tuple[str, str, str]] = [] + + class DummyAdmin: + def ready(self) -> bool: + return True + + def iter_users(self, page_size=200, brief=False): + return [{"id": "1", "username": "alice", "attributes": {}}] + + def get_user(self, user_id: str): + return {"id": user_id, "username": "alice", "attributes": {}} + + def set_user_attribute(self, username: str, key: str, value: str) -> None: + calls.append((username, key, value)) + + monkeypatch.setattr("ariadne.services.wger.keycloak_admin", DummyAdmin()) + monkeypatch.setattr( + "ariadne.services.wger.mailu.resolve_mailu_email", + lambda *_args, **_kwargs: "alice@bstein.dev", + ) + monkeypatch.setattr("ariadne.services.wger.random_password", lambda *_args: "pw") + + def fake_sync_user(self, *_args, **_kwargs): + return {"status": "ok", "detail": "ok"} + + monkeypatch.setattr(WgerService, "sync_user", fake_sync_user) + + svc = WgerService() + result = svc.sync_users() + assert result["status"] == "ok" + assert any(key == "wger_password" for _user, key, _value in calls) + assert any(key == "wger_password_updated_at" for _user, key, _value in calls) + + def test_firefly_sync_user_exec(monkeypatch) -> None: dummy = types.SimpleNamespace( firefly_namespace="finance", @@ -253,64 +282,197 @@ def test_firefly_run_cron(monkeypatch) -> None: assert result["status"] == "ok" -def test_mailu_sync_includes_force(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_sync_url="http://mailu", - mailu_sync_wait_timeout_sec=10.0, - mailu_db_host="localhost", - mailu_db_port=5432, - mailu_db_name="mailu", - mailu_db_user="mailu", - mailu_db_password="secret", - ) - client = DummyClient() - - monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.mailu.httpx.Client", lambda *args, **kwargs: client) - - svc = MailuService() - svc.sync("provision", force=True) - - assert client.url == "http://mailu" - assert client.payload["wait"] is True - assert client.payload["force"] is True - - -def test_mailu_sync_skips_without_url(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_sync_url="", - mailu_sync_wait_timeout_sec=10.0, - mailu_db_host="localhost", - mailu_db_port=5432, - mailu_db_name="mailu", - mailu_db_user="mailu", - mailu_db_password="secret", +def test_firefly_sync_users(monkeypatch) -> None: + dummy = types.SimpleNamespace( + firefly_namespace="finance", + firefly_user_sync_wait_timeout_sec=60.0, + firefly_pod_label="app=firefly", + firefly_container="firefly", + firefly_cron_base_url="http://firefly/cron", + firefly_cron_token="token", + firefly_cron_timeout_sec=10.0, mailu_domain="bstein.dev", ) - monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) - svc = MailuService() - assert svc.sync("provision") is None + monkeypatch.setattr("ariadne.services.firefly.settings", dummy) + + calls: list[tuple[str, str, str]] = [] + + class DummyAdmin: + def ready(self) -> bool: + return True + + def iter_users(self, page_size=200, brief=False): + return [{"id": "1", "username": "alice", "attributes": {}}] + + def get_user(self, user_id: str): + return {"id": user_id, "username": "alice", "attributes": {}} + + def set_user_attribute(self, username: str, key: str, value: str) -> None: + calls.append((username, key, value)) + + monkeypatch.setattr("ariadne.services.firefly.keycloak_admin", DummyAdmin()) + monkeypatch.setattr( + "ariadne.services.firefly.mailu.resolve_mailu_email", + lambda *_args, **_kwargs: "alice@bstein.dev", + ) + monkeypatch.setattr("ariadne.services.firefly.random_password", lambda *_args: "pw") + + def fake_sync_user(self, *_args, **_kwargs): + return {"status": "ok", "detail": "ok"} + + monkeypatch.setattr(FireflyService, "sync_user", fake_sync_user) + + svc = FireflyService() + result = svc.sync_users() + assert result["status"] == "ok" + assert any(key == "firefly_password" for _user, key, _value in calls) + assert any(key == "firefly_password_updated_at" for _user, key, _value in calls) -def test_mailu_sync_raises_on_error(monkeypatch) -> None: +def test_mailu_sync_updates_attrs(monkeypatch) -> None: dummy_settings = types.SimpleNamespace( - mailu_sync_url="http://mailu", - mailu_sync_wait_timeout_sec=10.0, + mailu_domain="bstein.dev", mailu_db_host="localhost", mailu_db_port=5432, mailu_db_name="mailu", mailu_db_user="mailu", mailu_db_password="secret", + mailu_default_quota=20000000000, + mailu_system_users=[], + mailu_system_password="", ) - client = DummyClient() - client.status_code = 500 - monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.mailu.httpx.Client", lambda *args, **kwargs: client) + monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.ready", lambda: True) + monkeypatch.setattr( + "ariadne.services.mailu.keycloak_admin.iter_users", + lambda *args, **kwargs: [ + { + "id": "1", + "username": "alice", + "enabled": True, + "email": "alice@example.com", + "attributes": {}, + "firstName": "Alice", + "lastName": "Example", + } + ], + ) + + updates: list[tuple[str, dict[str, object]]] = [] + monkeypatch.setattr( + "ariadne.services.mailu.keycloak_admin.update_user_safe", + lambda user_id, payload: updates.append((user_id, payload)), + ) + + mailbox_calls: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + "ariadne.services.mailu.MailuService._ensure_mailbox", + lambda self, _conn, email, password, display: mailbox_calls.append((email, password, display)) or True, + ) + + class DummyConn: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) svc = MailuService() - with pytest.raises(RuntimeError): - svc.sync("provision") + summary = svc.sync("provision", force=True) + + assert summary.processed == 1 + assert summary.updated == 1 + assert mailbox_calls + assert updates + assert "mailu_email" in updates[0][1]["attributes"] + + +def test_mailu_sync_skips_disabled(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_db_host="localhost", + mailu_db_port=5432, + mailu_db_name="mailu", + mailu_db_user="mailu", + mailu_db_password="secret", + mailu_default_quota=20000000000, + mailu_system_users=[], + mailu_system_password="", + ) + monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.ready", lambda: True) + monkeypatch.setattr( + "ariadne.services.mailu.keycloak_admin.iter_users", + lambda *args, **kwargs: [ + { + "id": "1", + "username": "alice", + "enabled": True, + "attributes": {"mailu_enabled": ["false"]}, + } + ], + ) + + mailbox_calls: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + "ariadne.services.mailu.MailuService._ensure_mailbox", + lambda self, _conn, email, password, display: mailbox_calls.append((email, password, display)) or True, + ) + + class DummyConn: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) + + svc = MailuService() + summary = svc.sync("provision") + + assert summary.skipped == 1 + assert mailbox_calls == [] + + +def test_mailu_sync_system_mailboxes(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_db_host="localhost", + mailu_db_port=5432, + mailu_db_name="mailu", + mailu_db_user="mailu", + mailu_db_password="secret", + mailu_default_quota=20000000000, + mailu_system_users=["no-reply-portal@bstein.dev"], + mailu_system_password="systempw", + ) + monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.ready", lambda: True) + monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.iter_users", lambda *args, **kwargs: []) + + mailbox_calls: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + "ariadne.services.mailu.MailuService._ensure_mailbox", + lambda self, _conn, email, password, display: mailbox_calls.append((email, password, display)) or True, + ) + + class DummyConn: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) + + svc = MailuService() + summary = svc.sync("schedule") + + assert summary.system_mailboxes == 1 + assert mailbox_calls[0][0] == "no-reply-portal@bstein.dev" def test_vaultwarden_invite_uses_admin_session(monkeypatch) -> None: diff --git a/tests/test_storage.py b/tests/test_storage.py index f38aedb..bdac0d5 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime -from ariadne.db.storage import Storage +from ariadne.db.storage import ScheduleState, Storage, TaskRunRecord class DummyDB: @@ -282,12 +282,33 @@ def test_list_pending_requests() -> None: def test_record_task_run_executes() -> None: db = DummyDB() storage = Storage(db) - storage.record_task_run("REQ1", "task", "ok", None, datetime.now(), datetime.now(), 5) + storage.record_task_run( + TaskRunRecord( + request_code="REQ1", + task="task", + status="ok", + detail=None, + started_at=datetime.now(), + finished_at=datetime.now(), + duration_ms=5, + ) + ) assert db.executed def test_update_schedule_state_executes() -> None: db = DummyDB() storage = Storage(db) - storage.update_schedule_state("task", "* * * * *", None, None, "ok", None, None, None) + storage.update_schedule_state( + ScheduleState( + task_name="task", + cron_expr="* * * * *", + last_started_at=None, + last_finished_at=None, + last_status="ok", + last_error=None, + last_duration_ms=None, + next_run_at=None, + ) + ) assert db.executed diff --git a/tests/test_utils.py b/tests/test_utils.py index 059a2c7..2e6bc78 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -35,6 +35,13 @@ def test_mailu_resolve_email_default() -> None: assert MailuService.resolve_mailu_email("alice", {}) == "alice@bstein.dev" +def test_mailu_resolve_email_fallback() -> None: + assert ( + MailuService.resolve_mailu_email("alice", {}, "alice@bstein.dev") + == "alice@bstein.dev" + ) + + def test_safe_error_detail_runtime() -> None: assert safe_error_detail(RuntimeError("boom"), "fallback") == "boom"