feat: absorb glue tasks and mailu events

This commit is contained in:
Brad Stein 2026-01-21 02:57:06 -03:00
parent 871ab9dae8
commit d1cbec8993
30 changed files with 3364 additions and 1484 deletions

1
Jenkinsfile vendored
View File

@ -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}" \

View File

@ -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)

View File

@ -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]:

View File

@ -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,
),
)

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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}",

View File

@ -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()

View File

@ -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()

View File

@ -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,
)

View File

@ -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(

View File

@ -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()

View File

@ -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}",

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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,
)

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -1,3 +1,4 @@
pytest==8.3.5
pytest-mock==3.14.0
slipcover==1.0.17
ruff==0.14.13

View File

@ -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

View File

@ -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"})

View File

@ -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"

View File

@ -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):

View File

@ -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:

View File

@ -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

View File

@ -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"