feat: absorb glue tasks and mailu events
This commit is contained in:
parent
871ab9dae8
commit
d1cbec8993
1
Jenkinsfile
vendored
1
Jenkinsfile
vendored
@ -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}" \
|
||||
|
||||
382
ariadne/app.py
382
ariadne/app.py
@ -1,17 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import threading
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
||||
|
||||
from .auth.keycloak import AuthContext, authenticator
|
||||
from .db.database import Database
|
||||
from .db.storage import Storage
|
||||
from .db.storage import Storage, TaskRunRecord
|
||||
from .manager.provisioning import ProvisioningManager
|
||||
from .metrics.metrics import record_task_run
|
||||
from .scheduler.cron import CronScheduler
|
||||
@ -20,6 +21,7 @@ from .services.firefly import firefly
|
||||
from .services.keycloak_admin import keycloak_admin
|
||||
from .services.keycloak_profile import run_profile_sync
|
||||
from .services.mailu import mailu
|
||||
from .services.mailu_events import mailu_events
|
||||
from .services.nextcloud import nextcloud
|
||||
from .services.image_sweeper import image_sweeper
|
||||
from .services.opensearch_prune import prune_indices
|
||||
@ -37,6 +39,27 @@ from .utils.passwords import random_password
|
||||
configure_logging(LogConfig(level=settings.log_level))
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountTaskContext:
|
||||
task_name: str
|
||||
username: str
|
||||
started: datetime
|
||||
extra: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PasswordResetRequest:
|
||||
task_name: str
|
||||
service_label: str
|
||||
username: str
|
||||
mailu_email: str
|
||||
password: str
|
||||
sync_fn: Callable[[], dict[str, Any]]
|
||||
password_attr: str
|
||||
updated_attr: str
|
||||
error_hint: str
|
||||
|
||||
db = Database(settings.portal_database_url)
|
||||
storage = Storage(db)
|
||||
provisioning = ProvisioningManager(db, storage)
|
||||
@ -88,6 +111,121 @@ def _require_account_access(ctx: AuthContext) -> None:
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
|
||||
|
||||
async def _read_json_payload(request: Request) -> dict[str, Any]:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _note_from_payload(payload: dict[str, Any]) -> str | None:
|
||||
note = payload.get("note") if isinstance(payload, dict) else None
|
||||
return str(note).strip() if isinstance(note, str) and note.strip() else None
|
||||
|
||||
|
||||
def _flags_from_payload(payload: dict[str, Any]) -> list[str]:
|
||||
flags_raw = payload.get("flags") if isinstance(payload, dict) else None
|
||||
return [flag for flag in flags_raw if isinstance(flag, str)] if isinstance(flags_raw, list) else []
|
||||
|
||||
|
||||
def _allowed_flag_groups() -> list[str]:
|
||||
if not keycloak_admin.ready():
|
||||
return settings.allowed_flag_groups
|
||||
try:
|
||||
return keycloak_admin.list_group_names(exclude={"admin"})
|
||||
except Exception:
|
||||
return settings.allowed_flag_groups
|
||||
|
||||
|
||||
def _resolve_mailu_email(username: str) -> str:
|
||||
mailu_email = f"{username}@{settings.mailu_domain}"
|
||||
try:
|
||||
user = keycloak_admin.find_user(username) or {}
|
||||
attrs = user.get("attributes") if isinstance(user, dict) else None
|
||||
if isinstance(attrs, dict):
|
||||
raw_mailu = attrs.get("mailu_email")
|
||||
if isinstance(raw_mailu, list) and raw_mailu:
|
||||
return str(raw_mailu[0])
|
||||
if isinstance(raw_mailu, str) and raw_mailu:
|
||||
return raw_mailu
|
||||
except Exception:
|
||||
return mailu_email
|
||||
return mailu_email
|
||||
|
||||
|
||||
def _record_account_task(ctx: AccountTaskContext, status: str, error_detail: str) -> None:
|
||||
finished = datetime.now(timezone.utc)
|
||||
duration_sec = (finished - ctx.started).total_seconds()
|
||||
record_task_run(ctx.task_name, status, duration_sec)
|
||||
try:
|
||||
storage.record_task_run(
|
||||
TaskRunRecord(
|
||||
request_code=None,
|
||||
task=ctx.task_name,
|
||||
status=status,
|
||||
detail=error_detail or None,
|
||||
started_at=ctx.started,
|
||||
finished_at=finished,
|
||||
duration_ms=int(duration_sec * 1000),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
detail = {"username": ctx.username, "status": status, "error": error_detail}
|
||||
if ctx.extra:
|
||||
detail.update(ctx.extra)
|
||||
_record_event(ctx.task_name, detail)
|
||||
|
||||
|
||||
def _run_password_reset(request: PasswordResetRequest) -> JSONResponse:
|
||||
started = datetime.now(timezone.utc)
|
||||
task_ctx = AccountTaskContext(
|
||||
task_name=request.task_name,
|
||||
username=request.username,
|
||||
started=started,
|
||||
extra={"mailu_email": request.mailu_email},
|
||||
)
|
||||
status = "ok"
|
||||
error_detail = ""
|
||||
logger.info(
|
||||
f"{request.service_label} password reset requested",
|
||||
extra={"event": request.task_name, "username": request.username},
|
||||
)
|
||||
try:
|
||||
result = request.sync_fn()
|
||||
status_val = result.get("status") if isinstance(result, dict) else "error"
|
||||
if status_val != "ok":
|
||||
raise RuntimeError(f"{request.service_label} sync {status_val}")
|
||||
|
||||
keycloak_admin.set_user_attribute(
|
||||
request.username,
|
||||
request.password_attr,
|
||||
request.password,
|
||||
)
|
||||
keycloak_admin.set_user_attribute(
|
||||
request.username,
|
||||
request.updated_attr,
|
||||
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"{request.service_label} password reset completed",
|
||||
extra={"event": request.task_name, "username": request.username},
|
||||
)
|
||||
return JSONResponse({"status": "ok", "password": request.password})
|
||||
except HTTPException as exc:
|
||||
status = "error"
|
||||
error_detail = str(exc.detail)
|
||||
raise
|
||||
except Exception as exc:
|
||||
status = "error"
|
||||
error_detail = safe_error_detail(exc, request.error_hint)
|
||||
raise HTTPException(status_code=502, detail=error_detail)
|
||||
finally:
|
||||
_record_account_task(task_ctx, status, error_detail)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _startup() -> None:
|
||||
db.ensure_schema()
|
||||
@ -115,7 +253,17 @@ def _startup() -> None:
|
||||
settings.keycloak_profile_cron,
|
||||
run_profile_sync,
|
||||
)
|
||||
scheduler.add_task(
|
||||
"schedule.wger_user_sync",
|
||||
settings.wger_user_sync_cron,
|
||||
lambda: wger.sync_users(),
|
||||
)
|
||||
scheduler.add_task("schedule.wger_admin", settings.wger_admin_cron, lambda: wger.ensure_admin(wait=False))
|
||||
scheduler.add_task(
|
||||
"schedule.firefly_user_sync",
|
||||
settings.firefly_user_sync_cron,
|
||||
lambda: firefly.sync_users(),
|
||||
)
|
||||
scheduler.add_task(
|
||||
"schedule.firefly_cron",
|
||||
settings.firefly_cron,
|
||||
@ -176,7 +324,9 @@ def _startup() -> None:
|
||||
"nextcloud_cron": settings.nextcloud_cron,
|
||||
"nextcloud_maintenance_cron": settings.nextcloud_maintenance_cron,
|
||||
"vaultwarden_cron": settings.vaultwarden_sync_cron,
|
||||
"wger_user_sync_cron": settings.wger_user_sync_cron,
|
||||
"wger_admin_cron": settings.wger_admin_cron,
|
||||
"firefly_user_sync_cron": settings.firefly_user_sync_cron,
|
||||
"firefly_cron": settings.firefly_cron,
|
||||
"pod_cleaner_cron": settings.pod_cleaner_cron,
|
||||
"opensearch_prune_cron": settings.opensearch_prune_cron,
|
||||
@ -317,22 +467,10 @@ async def approve_access_request(
|
||||
) -> JSONResponse:
|
||||
_require_admin(ctx)
|
||||
with task_context("admin.access.approve"):
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
|
||||
flags_raw = payload.get("flags") if isinstance(payload, dict) else None
|
||||
flags = [f for f in flags_raw if isinstance(f, str)] if isinstance(flags_raw, list) else []
|
||||
allowed_flags = settings.allowed_flag_groups
|
||||
if keycloak_admin.ready():
|
||||
try:
|
||||
allowed_flags = keycloak_admin.list_group_names(exclude={"admin"})
|
||||
except Exception:
|
||||
allowed_flags = settings.allowed_flag_groups
|
||||
flags = [f for f in flags if f in allowed_flags]
|
||||
note = payload.get("note") if isinstance(payload, dict) else None
|
||||
note = str(note).strip() if isinstance(note, str) else None
|
||||
payload = await _read_json_payload(request)
|
||||
allowed_flags = _allowed_flag_groups()
|
||||
flags = [flag for flag in _flags_from_payload(payload) if flag in allowed_flags]
|
||||
note = _note_from_payload(payload)
|
||||
|
||||
decided_by = ctx.username or ""
|
||||
try:
|
||||
@ -407,12 +545,8 @@ async def deny_access_request(
|
||||
) -> JSONResponse:
|
||||
_require_admin(ctx)
|
||||
with task_context("admin.access.deny"):
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
note = payload.get("note") if isinstance(payload, dict) else None
|
||||
note = str(note).strip() if isinstance(note, str) else None
|
||||
payload = await _read_json_payload(request)
|
||||
note = _note_from_payload(payload)
|
||||
decided_by = ctx.username or ""
|
||||
|
||||
try:
|
||||
@ -480,7 +614,7 @@ def rotate_mailu_password(ctx: AuthContext = Depends(_require_auth)) -> JSONResp
|
||||
started = datetime.now(timezone.utc)
|
||||
status = "ok"
|
||||
error_detail = ""
|
||||
sync_enabled = bool(settings.mailu_sync_url)
|
||||
sync_enabled = mailu.ready()
|
||||
sync_ok = False
|
||||
sync_error = ""
|
||||
nextcloud_sync: dict[str, Any] = {"status": "skipped"}
|
||||
@ -538,13 +672,15 @@ def rotate_mailu_password(ctx: AuthContext = Depends(_require_auth)) -> JSONResp
|
||||
record_task_run("mailu_rotate", status, duration_sec)
|
||||
try:
|
||||
storage.record_task_run(
|
||||
None,
|
||||
"mailu_rotate",
|
||||
status,
|
||||
error_detail or None,
|
||||
started,
|
||||
finished,
|
||||
int(duration_sec * 1000),
|
||||
TaskRunRecord(
|
||||
request_code=None,
|
||||
task="mailu_rotate",
|
||||
status=status,
|
||||
detail=error_detail or None,
|
||||
started_at=started,
|
||||
finished_at=finished,
|
||||
duration_ms=int(duration_sec * 1000),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@ -572,71 +708,20 @@ def reset_wger_password(ctx: AuthContext = Depends(_require_auth)) -> JSONRespon
|
||||
raise HTTPException(status_code=400, detail="missing username")
|
||||
|
||||
with task_context("account.wger_reset"):
|
||||
mailu_email = f"{username}@{settings.mailu_domain}"
|
||||
try:
|
||||
user = keycloak_admin.find_user(username) or {}
|
||||
attrs = user.get("attributes") if isinstance(user, dict) else None
|
||||
if isinstance(attrs, dict):
|
||||
raw_mailu = attrs.get("mailu_email")
|
||||
if isinstance(raw_mailu, list) and raw_mailu:
|
||||
mailu_email = str(raw_mailu[0])
|
||||
elif isinstance(raw_mailu, str) and raw_mailu:
|
||||
mailu_email = raw_mailu
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
started = datetime.now(timezone.utc)
|
||||
status = "ok"
|
||||
error_detail = ""
|
||||
logger.info("wger password reset requested", extra={"event": "wger_reset", "username": username})
|
||||
try:
|
||||
password = random_password()
|
||||
result = wger.sync_user(username, mailu_email, password, wait=True)
|
||||
status_val = result.get("status") if isinstance(result, dict) else "error"
|
||||
if status_val != "ok":
|
||||
raise RuntimeError(f"wger sync {status_val}")
|
||||
|
||||
keycloak_admin.set_user_attribute(username, "wger_password", password)
|
||||
keycloak_admin.set_user_attribute(
|
||||
username,
|
||||
"wger_password_updated_at",
|
||||
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
)
|
||||
|
||||
logger.info("wger password reset completed", extra={"event": "wger_reset", "username": username})
|
||||
return JSONResponse({"status": "ok", "password": password})
|
||||
except HTTPException as exc:
|
||||
status = "error"
|
||||
error_detail = str(exc.detail)
|
||||
raise
|
||||
except Exception as exc:
|
||||
status = "error"
|
||||
error_detail = safe_error_detail(exc, "wger sync failed")
|
||||
raise HTTPException(status_code=502, detail=error_detail)
|
||||
finally:
|
||||
finished = datetime.now(timezone.utc)
|
||||
duration_sec = (finished - started).total_seconds()
|
||||
record_task_run("wger_reset", status, duration_sec)
|
||||
try:
|
||||
storage.record_task_run(
|
||||
None,
|
||||
"wger_reset",
|
||||
status,
|
||||
error_detail or None,
|
||||
started,
|
||||
finished,
|
||||
int(duration_sec * 1000),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_record_event(
|
||||
"wger_reset",
|
||||
{
|
||||
"username": username,
|
||||
"status": status,
|
||||
"error": error_detail,
|
||||
},
|
||||
)
|
||||
mailu_email = _resolve_mailu_email(username)
|
||||
password = random_password()
|
||||
request = PasswordResetRequest(
|
||||
task_name="wger_reset",
|
||||
service_label="wger",
|
||||
username=username,
|
||||
mailu_email=mailu_email,
|
||||
password=password,
|
||||
sync_fn=lambda: wger.sync_user(username, mailu_email, password, wait=True),
|
||||
password_attr="wger_password",
|
||||
updated_attr="wger_password_updated_at",
|
||||
error_hint="wger sync failed",
|
||||
)
|
||||
return _run_password_reset(request)
|
||||
|
||||
|
||||
@app.post("/api/account/firefly/reset")
|
||||
@ -650,71 +735,20 @@ def reset_firefly_password(ctx: AuthContext = Depends(_require_auth)) -> JSONRes
|
||||
raise HTTPException(status_code=400, detail="missing username")
|
||||
|
||||
with task_context("account.firefly_reset"):
|
||||
mailu_email = f"{username}@{settings.mailu_domain}"
|
||||
try:
|
||||
user = keycloak_admin.find_user(username) or {}
|
||||
attrs = user.get("attributes") if isinstance(user, dict) else None
|
||||
if isinstance(attrs, dict):
|
||||
raw_mailu = attrs.get("mailu_email")
|
||||
if isinstance(raw_mailu, list) and raw_mailu:
|
||||
mailu_email = str(raw_mailu[0])
|
||||
elif isinstance(raw_mailu, str) and raw_mailu:
|
||||
mailu_email = raw_mailu
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
started = datetime.now(timezone.utc)
|
||||
status = "ok"
|
||||
error_detail = ""
|
||||
logger.info("firefly password reset requested", extra={"event": "firefly_reset", "username": username})
|
||||
try:
|
||||
password = random_password(24)
|
||||
result = firefly.sync_user(mailu_email, password, wait=True)
|
||||
status_val = result.get("status") if isinstance(result, dict) else "error"
|
||||
if status_val != "ok":
|
||||
raise RuntimeError(f"firefly sync {status_val}")
|
||||
|
||||
keycloak_admin.set_user_attribute(username, "firefly_password", password)
|
||||
keycloak_admin.set_user_attribute(
|
||||
username,
|
||||
"firefly_password_updated_at",
|
||||
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
)
|
||||
|
||||
logger.info("firefly password reset completed", extra={"event": "firefly_reset", "username": username})
|
||||
return JSONResponse({"status": "ok", "password": password})
|
||||
except HTTPException as exc:
|
||||
status = "error"
|
||||
error_detail = str(exc.detail)
|
||||
raise
|
||||
except Exception as exc:
|
||||
status = "error"
|
||||
error_detail = safe_error_detail(exc, "firefly sync failed")
|
||||
raise HTTPException(status_code=502, detail=error_detail)
|
||||
finally:
|
||||
finished = datetime.now(timezone.utc)
|
||||
duration_sec = (finished - started).total_seconds()
|
||||
record_task_run("firefly_reset", status, duration_sec)
|
||||
try:
|
||||
storage.record_task_run(
|
||||
None,
|
||||
"firefly_reset",
|
||||
status,
|
||||
error_detail or None,
|
||||
started,
|
||||
finished,
|
||||
int(duration_sec * 1000),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_record_event(
|
||||
"firefly_reset",
|
||||
{
|
||||
"username": username,
|
||||
"status": status,
|
||||
"error": error_detail,
|
||||
},
|
||||
)
|
||||
mailu_email = _resolve_mailu_email(username)
|
||||
password = random_password(24)
|
||||
request = PasswordResetRequest(
|
||||
task_name="firefly_reset",
|
||||
service_label="firefly",
|
||||
username=username,
|
||||
mailu_email=mailu_email,
|
||||
password=password,
|
||||
sync_fn=lambda: firefly.sync_user(mailu_email, password, wait=True),
|
||||
password_attr="firefly_password",
|
||||
updated_attr="firefly_password_updated_at",
|
||||
error_hint="firefly sync failed",
|
||||
)
|
||||
return _run_password_reset(request)
|
||||
|
||||
|
||||
@app.post("/api/account/nextcloud/mail/sync")
|
||||
@ -770,13 +804,15 @@ async def nextcloud_mail_sync(request: Request, ctx: AuthContext = Depends(_requ
|
||||
record_task_run("nextcloud_sync", status, duration_sec)
|
||||
try:
|
||||
storage.record_task_run(
|
||||
None,
|
||||
"nextcloud_sync",
|
||||
status,
|
||||
error_detail or None,
|
||||
started,
|
||||
finished,
|
||||
int(duration_sec * 1000),
|
||||
TaskRunRecord(
|
||||
request_code=None,
|
||||
task="nextcloud_sync",
|
||||
status=status,
|
||||
detail=error_detail or None,
|
||||
started_at=started,
|
||||
finished_at=finished,
|
||||
duration_ms=int(duration_sec * 1000),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@ -789,3 +825,9 @@ async def nextcloud_mail_sync(request: Request, ctx: AuthContext = Depends(_requ
|
||||
"error": error_detail,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/events")
|
||||
def mailu_event_listener(payload: dict[str, Any] | None = Body(default=None)) -> Response:
|
||||
status_code, response = mailu_events.handle_event(payload)
|
||||
return JSONResponse(response, status_code=status_code)
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
151
ariadne/services/mailu_events.py
Normal file
151
ariadne/services/mailu_events.py
Normal 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,
|
||||
)
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
25
ariadne/utils/name_generator.py
Normal file
25
ariadne/utils/name_generator.py
Normal 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
|
||||
@ -1,3 +1,4 @@
|
||||
pytest==8.3.5
|
||||
pytest-mock==3.14.0
|
||||
slipcover==1.0.17
|
||||
ruff==0.14.13
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"})
|
||||
|
||||
|
||||
54
tests/test_mailu_events.py
Normal file
54
tests/test_mailu_events.py
Normal 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"
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user