ariadne: add password-rotation checks + tune schedules

This commit is contained in:
Brad Stein 2026-01-22 02:47:42 -03:00
parent 8578524e56
commit b45911c50e
7 changed files with 477 additions and 18 deletions

View File

@ -19,6 +19,7 @@ from .mailu import mailu
HTTP_OK = 200 HTTP_OK = 200
FIREFLY_PASSWORD_ATTR = "firefly_password" FIREFLY_PASSWORD_ATTR = "firefly_password"
FIREFLY_PASSWORD_UPDATED_ATTR = "firefly_password_updated_at" FIREFLY_PASSWORD_UPDATED_ATTR = "firefly_password_updated_at"
FIREFLY_PASSWORD_ROTATED_ATTR = "firefly_password_rotated_at"
logger = get_logger(__name__) logger = get_logger(__name__)
@ -141,11 +142,109 @@ _FIREFLY_SYNC_SCRIPT = textwrap.dedent(
""" """
).strip() ).strip()
_FIREFLY_PASSWORD_CHECK_SCRIPT = textwrap.dedent(
"""
<?php
declare(strict_types=1);
use FireflyIII\\Support\\Facades\\FireflyConfig;
use FireflyIII\\User;
use Illuminate\\Contracts\\Console\\Kernel as ConsoleKernel;
use Illuminate\\Support\\Facades\\Hash;
function log_line(string $message): void
{
fwrite(STDOUT, $message . PHP_EOL);
}
function error_line(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
function find_app_root(): string
{
$candidates = [];
$env_root = getenv('FIREFLY_APP_DIR') ?: '';
if ($env_root !== '') {
$candidates[] = $env_root;
}
$candidates[] = '/var/www/html';
$candidates[] = '/var/www/firefly-iii';
$candidates[] = '/app';
foreach ($candidates as $candidate) {
if (!is_dir($candidate)) {
continue;
}
if (file_exists($candidate . '/vendor/autoload.php')) {
return $candidate;
}
}
return '';
}
$email = trim((string) getenv('FIREFLY_USER_EMAIL'));
$password = (string) getenv('FIREFLY_USER_PASSWORD');
if ($email === '' || $password === '') {
error_line('missing FIREFLY_USER_EMAIL or FIREFLY_USER_PASSWORD');
exit(2);
}
$root = find_app_root();
if ($root === '') {
error_line('firefly app root not found');
exit(2);
}
$autoload = $root . '/vendor/autoload.php';
$app_bootstrap = $root . '/bootstrap/app.php';
if (!file_exists($autoload) || !file_exists($app_bootstrap)) {
error_line('firefly bootstrap files missing');
exit(2);
}
require $autoload;
$app = require $app_bootstrap;
$kernel = $app->make(ConsoleKernel::class);
$kernel->bootstrap();
try {
FireflyConfig::set('single_user_mode', true);
} catch (Throwable $exc) {
error_line('failed to enforce single_user_mode: ' . $exc->getMessage());
}
$existing_user = User::where('email', $email)->first();
if (!$existing_user) {
error_line('firefly user missing');
exit(3);
}
if (Hash::check($password, $existing_user->password)) {
log_line('password match');
exit(0);
}
log_line('password mismatch');
exit(1);
"""
).strip()
def _firefly_exec_command() -> str: def _firefly_exec_command() -> str:
return f"php <<'PHP'\n{_FIREFLY_SYNC_SCRIPT}\nPHP" return f"php <<'PHP'\n{_FIREFLY_SYNC_SCRIPT}\nPHP"
def _firefly_check_command() -> str:
return f"php <<'PHP'\n{_FIREFLY_PASSWORD_CHECK_SCRIPT}\nPHP"
@dataclass(frozen=True) @dataclass(frozen=True)
class FireflySyncSummary: class FireflySyncSummary:
processed: int processed: int
@ -194,6 +293,7 @@ class FireflySyncInput:
password: str password: str
password_generated: bool password_generated: bool
updated_at: str updated_at: str
rotated_at: str
def _extract_attr(attrs: Any, key: str) -> str: def _extract_attr(attrs: Any, key: str) -> str:
@ -264,6 +364,15 @@ def _set_firefly_updated_at(username: str) -> bool:
return True return True
def _set_firefly_rotated_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_ROTATED_ATTR, now_iso)
except Exception:
return False
return True
def _normalize_user(user: dict[str, Any]) -> tuple[UserSyncOutcome | None, UserIdentity | None]: def _normalize_user(user: dict[str, Any]) -> tuple[UserSyncOutcome | None, UserIdentity | None]:
username = user.get("username") if isinstance(user.get("username"), str) else "" username = user.get("username") if isinstance(user.get("username"), str) else ""
if _should_skip_user(user, username): if _should_skip_user(user, username):
@ -339,12 +448,14 @@ def _build_sync_input(user: dict[str, Any]) -> FireflySyncInput | UserSyncOutcom
return UserSyncOutcome("failed", "missing firefly password") return UserSyncOutcome("failed", "missing firefly password")
updated_at = _extract_attr(attrs, FIREFLY_PASSWORD_UPDATED_ATTR) updated_at = _extract_attr(attrs, FIREFLY_PASSWORD_UPDATED_ATTR)
rotated_at = _extract_attr(attrs, FIREFLY_PASSWORD_ROTATED_ATTR)
return FireflySyncInput( return FireflySyncInput(
username=identity.username, username=identity.username,
mailu_email=mailu_email, mailu_email=mailu_email,
password=password, password=password,
password_generated=password_generated, password_generated=password_generated,
updated_at=updated_at, updated_at=updated_at,
rotated_at=rotated_at,
) )
@ -383,6 +494,39 @@ class FireflyService:
output = (result.stdout or result.stderr).strip() output = (result.stdout or result.stderr).strip()
return {"status": "ok", "detail": output} return {"status": "ok", "detail": output}
def check_password(self, email: str, password: str) -> dict[str, Any]:
email = (email or "").strip()
if not email:
raise RuntimeError("missing email")
if not password:
raise RuntimeError("missing password")
if not settings.firefly_namespace:
raise RuntimeError("firefly sync not configured")
env = {
"FIREFLY_USER_EMAIL": email,
"FIREFLY_USER_PASSWORD": password,
}
try:
result = self._executor.exec(
_firefly_check_command(),
env=env,
timeout_sec=settings.firefly_user_sync_wait_timeout_sec,
check=False,
)
except (ExecError, PodSelectionError, TimeoutError) as exc:
return {"status": "error", "detail": str(exc)}
detail = (result.stdout or result.stderr).strip()
if result.exit_code == 0:
return {"status": "match", "detail": detail}
if result.exit_code == 1:
return {"status": "mismatch", "detail": detail}
if result.exit_code == 3:
return {"status": "missing", "detail": detail or "user missing"}
return {"status": "error", "detail": detail or f"exit_code={result.exit_code}"}
def run_cron(self) -> dict[str, Any]: def run_cron(self) -> dict[str, Any]:
if not settings.firefly_cron_token: if not settings.firefly_cron_token:
raise RuntimeError("firefly cron token missing") raise RuntimeError("firefly cron token missing")
@ -401,7 +545,19 @@ class FireflyService:
if isinstance(prepared, UserSyncOutcome): if isinstance(prepared, UserSyncOutcome):
return prepared return prepared
if _should_skip_sync(prepared.password_generated, prepared.updated_at): if _should_skip_sync(prepared.password_generated, prepared.updated_at):
return UserSyncOutcome("skipped") if prepared.rotated_at:
return UserSyncOutcome("skipped")
check = self.check_password(prepared.mailu_email, prepared.password)
status = check.get("status") if isinstance(check, dict) else "error"
if status == "match":
return UserSyncOutcome("skipped")
if status == "mismatch":
if not _set_firefly_rotated_at(prepared.username):
return UserSyncOutcome("failed", "failed to set rotated_at")
return UserSyncOutcome("synced")
detail = check.get("detail") if isinstance(check, dict) else ""
detail = detail or "password check failed"
return UserSyncOutcome("failed", detail)
result = self.sync_user(prepared.mailu_email, prepared.password, wait=True) result = self.sync_user(prepared.mailu_email, prepared.password, wait=True)
result_status = result.get("status") if isinstance(result, dict) else "error" result_status = result.get("status") if isinstance(result, dict) else "error"

View File

@ -54,21 +54,24 @@ class VaultwardenService:
return [url for url in urls if url] return [url for url in urls if url]
@staticmethod @staticmethod
def _already_present(resp: httpx.Response) -> bool: def _invite_conflict_status(resp: httpx.Response) -> str | None:
try: try:
body = resp.text or "" body = resp.text or ""
except Exception: except Exception:
body = "" body = ""
lowered = body.lower() lowered = body.lower()
return any( if "already invited" in lowered:
return "invited"
if any(
marker in lowered marker in lowered
for marker in ( for marker in (
"already invited",
"already exists", "already exists",
"already registered", "already registered",
"user already exists", "user already exists",
) )
) ):
return "already_present"
return None
def _invite_via(self, base_url: str, email: str) -> VaultwardenInvite | None: def _invite_via(self, base_url: str, email: str) -> VaultwardenInvite | None:
if not base_url: if not base_url:
@ -81,8 +84,14 @@ class VaultwardenService:
result = self._rate_limited() result = self._rate_limited()
elif resp.status_code in {HTTP_OK, HTTP_CREATED, HTTP_NO_CONTENT}: elif resp.status_code in {HTTP_OK, HTTP_CREATED, HTTP_NO_CONTENT}:
result = VaultwardenInvite(ok=True, status="invited", detail="invite created") result = VaultwardenInvite(ok=True, status="invited", detail="invite created")
elif resp.status_code in {HTTP_BAD_REQUEST, HTTP_CONFLICT} and self._already_present(resp): elif resp.status_code in {HTTP_BAD_REQUEST, HTTP_CONFLICT}:
result = VaultwardenInvite(ok=True, status="already_present", detail="user already present") status = self._invite_conflict_status(resp)
if status == "invited":
result = VaultwardenInvite(ok=True, status="invited", detail="user already invited")
elif status == "already_present":
result = VaultwardenInvite(ok=True, status="already_present", detail="user already present")
else:
result = VaultwardenInvite(ok=False, status="error", detail=f"status {resp.status_code}")
else: else:
result = VaultwardenInvite(ok=False, status="error", detail=f"status {resp.status_code}") result = VaultwardenInvite(ok=False, status="error", detail=f"status {resp.status_code}")
except Exception as exc: except Exception as exc:

View File

@ -15,6 +15,7 @@ from .vaultwarden import vaultwarden
VAULTWARDEN_EMAIL_ATTR = "vaultwarden_email" VAULTWARDEN_EMAIL_ATTR = "vaultwarden_email"
VAULTWARDEN_STATUS_ATTR = "vaultwarden_status" VAULTWARDEN_STATUS_ATTR = "vaultwarden_status"
VAULTWARDEN_SYNCED_AT_ATTR = "vaultwarden_synced_at" VAULTWARDEN_SYNCED_AT_ATTR = "vaultwarden_synced_at"
VAULTWARDEN_MASTER_ATTR = "vaultwarden_master_password_set_at"
logger = get_logger(__name__) logger = get_logger(__name__)
@ -161,11 +162,16 @@ def _has_pending_failures(users: list[dict[str, Any]]) -> bool:
attrs = user.get("attributes") if isinstance(user.get("attributes"), dict) else {} attrs = user.get("attributes") if isinstance(user.get("attributes"), dict) else {}
status = _extract_attr(attrs, VAULTWARDEN_STATUS_ATTR) status = _extract_attr(attrs, VAULTWARDEN_STATUS_ATTR)
synced_at = _extract_attr(attrs, VAULTWARDEN_SYNCED_AT_ATTR) synced_at = _extract_attr(attrs, VAULTWARDEN_SYNCED_AT_ATTR)
master_set_at = _extract_attr(attrs, VAULTWARDEN_MASTER_ATTR)
synced_ts = _parse_synced_at(synced_at) synced_ts = _parse_synced_at(synced_at)
if not status: if not status:
return True return True
if status in {"invited", "already_present"} and not synced_at: if status in {"invited", "already_present"} and not synced_at:
return True return True
if status == "already_present" and not master_set_at:
return True
if status == "invited" and _should_refresh_invite(synced_ts):
return True
if status in {"error", "rate_limited"} and not _cooldown_active(status, synced_ts): if status in {"error", "rate_limited"} and not _cooldown_active(status, synced_ts):
return True return True
return False return False
@ -183,6 +189,19 @@ def _set_sync_status(username: str, status: str) -> None:
return return
def _set_master_password_set(username: str, full_user: dict[str, Any]) -> None:
if _extract_attr(full_user.get("attributes"), VAULTWARDEN_MASTER_ATTR):
return
try:
_set_user_attribute(
username,
VAULTWARDEN_MASTER_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: def _ensure_email_attrs(username: str, full_user: dict[str, Any], email: str) -> None:
try: try:
_set_user_attribute_if_missing(username, full_user, "mailu_email", email) _set_user_attribute_if_missing(username, full_user, "mailu_email", email)
@ -191,18 +210,34 @@ def _ensure_email_attrs(username: str, full_user: dict[str, Any], email: str) ->
return return
def _should_refresh_invite(synced_ts: float | None) -> bool:
if synced_ts is None:
return True
return (time.time() - synced_ts) >= settings.vaultwarden_invite_refresh_sec
def _handle_existing_invite( def _handle_existing_invite(
username: str, username: str,
current_status: str, current_status: str,
current_synced_at: str, current_synced_at: str,
current_synced_ts: float | None,
full_user: dict[str, Any],
counters: VaultwardenSyncCounters, counters: VaultwardenSyncCounters,
) -> bool: ) -> bool:
if current_status not in {"invited", "already_present"}: if current_status not in {"invited", "already_present"}:
return False return False
if not current_synced_at: if current_status == "already_present":
_set_sync_status(username, current_status) if not current_synced_at:
counters.skipped += 1 _set_sync_status(username, current_status)
return True _set_master_password_set(username, full_user)
counters.skipped += 1
return True
if not _should_refresh_invite(current_synced_ts):
if not current_synced_at:
_set_sync_status(username, current_status)
counters.skipped += 1
return True
return False
def _sync_user( def _sync_user(
@ -227,7 +262,14 @@ def _sync_user(
counters.skipped += 1 counters.skipped += 1
else: else:
_ensure_email_attrs(username, full_user, email) _ensure_email_attrs(username, full_user, email)
if _handle_existing_invite(username, current_status, current_synced_at, counters): if _handle_existing_invite(
username,
current_status,
current_synced_at,
current_synced_ts,
full_user,
counters,
):
status = None status = None
else: else:
counters.processed += 1 counters.processed += 1
@ -239,6 +281,8 @@ def _sync_user(
else: else:
counters.failures += 1 counters.failures += 1
_set_sync_status(username, result.status) _set_sync_status(username, result.status)
if result.status == "already_present":
_set_master_password_set(username, full_user)
return status, ok return status, ok

View File

@ -16,6 +16,7 @@ from .mailu import mailu
WGER_PASSWORD_ATTR = "wger_password" WGER_PASSWORD_ATTR = "wger_password"
WGER_PASSWORD_UPDATED_ATTR = "wger_password_updated_at" WGER_PASSWORD_UPDATED_ATTR = "wger_password_updated_at"
WGER_PASSWORD_ROTATED_ATTR = "wger_password_rotated_at"
logger = get_logger(__name__) logger = get_logger(__name__)
@ -142,12 +143,67 @@ _WGER_SYNC_SCRIPT = textwrap.dedent(
""" """
).strip() ).strip()
_WGER_PASSWORD_CHECK_SCRIPT = textwrap.dedent(
"""
from __future__ import annotations
import os
import sys
import django
def _env(name: str, default: str = "") -> str:
value = os.getenv(name, default)
return value.strip() if isinstance(value, str) else ""
def _setup_django() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.main")
django.setup()
def main() -> int:
username = _env("WGER_USERNAME")
password = _env("WGER_PASSWORD")
if not username or not password:
print("missing username or password")
return 2
_setup_django()
from django.contrib.auth.models import User
user = User.objects.filter(username=username).first()
if not user:
print(f"user {username} missing")
return 3
if user.check_password(password):
print("password match")
return 0
print("password mismatch")
return 1
if __name__ == "__main__":
sys.exit(main())
"""
).strip()
def _wger_exec_command() -> str: def _wger_exec_command() -> str:
bootstrap = "if [ -f /vault/secrets/wger-env ]; then . /vault/secrets/wger-env; fi" bootstrap = ". /vault/secrets/wger-env >/dev/null 2>&1 || true"
return f"{bootstrap}\npython3 - <<'PY'\n{_WGER_SYNC_SCRIPT}\nPY" return f"{bootstrap}\npython3 - <<'PY'\n{_WGER_SYNC_SCRIPT}\nPY"
def _wger_check_command() -> str:
bootstrap = ". /vault/secrets/wger-env >/dev/null 2>&1 || true"
return f"{bootstrap}\npython3 - <<'PY'\n{_WGER_PASSWORD_CHECK_SCRIPT}\nPY"
@dataclass(frozen=True) @dataclass(frozen=True)
class WgerSyncSummary: class WgerSyncSummary:
processed: int processed: int
@ -196,6 +252,7 @@ class WgerSyncInput:
password: str password: str
password_generated: bool password_generated: bool
updated_at: str updated_at: str
rotated_at: str
def _extract_attr(attrs: Any, key: str) -> str: def _extract_attr(attrs: Any, key: str) -> str:
@ -266,6 +323,15 @@ def _set_wger_updated_at(username: str) -> bool:
return True return True
def _set_wger_rotated_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_ROTATED_ATTR, now_iso)
except Exception:
return False
return True
def _normalize_user(user: dict[str, Any]) -> tuple[UserSyncOutcome | None, UserIdentity | None]: def _normalize_user(user: dict[str, Any]) -> tuple[UserSyncOutcome | None, UserIdentity | None]:
username = user.get("username") if isinstance(user.get("username"), str) else "" username = user.get("username") if isinstance(user.get("username"), str) else ""
if _should_skip_user(user, username): if _should_skip_user(user, username):
@ -341,12 +407,14 @@ def _build_sync_input(user: dict[str, Any]) -> WgerSyncInput | UserSyncOutcome:
return UserSyncOutcome("failed", "missing wger password") return UserSyncOutcome("failed", "missing wger password")
updated_at = _extract_attr(attrs, WGER_PASSWORD_UPDATED_ATTR) updated_at = _extract_attr(attrs, WGER_PASSWORD_UPDATED_ATTR)
rotated_at = _extract_attr(attrs, WGER_PASSWORD_ROTATED_ATTR)
return WgerSyncInput( return WgerSyncInput(
username=identity.username, username=identity.username,
mailu_email=mailu_email, mailu_email=mailu_email,
password=password, password=password,
password_generated=password_generated, password_generated=password_generated,
updated_at=updated_at, updated_at=updated_at,
rotated_at=rotated_at,
) )
@ -386,6 +454,39 @@ class WgerService:
output = (result.stdout or result.stderr).strip() output = (result.stdout or result.stderr).strip()
return {"status": "ok", "detail": output} return {"status": "ok", "detail": output}
def check_password(self, username: str, password: str) -> dict[str, Any]:
username = (username or "").strip()
if not username:
raise RuntimeError("missing username")
if not password:
raise RuntimeError("missing password")
if not settings.wger_namespace:
raise RuntimeError("wger sync not configured")
env = {
"WGER_USERNAME": username,
"WGER_PASSWORD": password,
}
try:
result = self._executor.exec(
_wger_check_command(),
env=env,
timeout_sec=settings.wger_user_sync_wait_timeout_sec,
check=False,
)
except (ExecError, PodSelectionError, TimeoutError) as exc:
return {"status": "error", "detail": str(exc)}
detail = (result.stdout or result.stderr).strip()
if result.exit_code == 0:
return {"status": "match", "detail": detail}
if result.exit_code == 1:
return {"status": "mismatch", "detail": detail}
if result.exit_code == 3:
return {"status": "missing", "detail": detail or "user missing"}
return {"status": "error", "detail": detail or f"exit_code={result.exit_code}"}
def ensure_admin(self, wait: bool = False) -> dict[str, Any]: def ensure_admin(self, wait: bool = False) -> dict[str, Any]:
if not settings.wger_namespace: if not settings.wger_namespace:
raise RuntimeError("wger admin sync not configured") raise RuntimeError("wger admin sync not configured")
@ -415,7 +516,19 @@ class WgerService:
if isinstance(prepared, UserSyncOutcome): if isinstance(prepared, UserSyncOutcome):
return prepared return prepared
if _should_skip_sync(prepared.password_generated, prepared.updated_at): if _should_skip_sync(prepared.password_generated, prepared.updated_at):
return UserSyncOutcome("skipped") if prepared.rotated_at:
return UserSyncOutcome("skipped")
check = self.check_password(prepared.username, prepared.password)
status = check.get("status") if isinstance(check, dict) else "error"
if status == "match":
return UserSyncOutcome("skipped")
if status == "mismatch":
if not _set_wger_rotated_at(prepared.username):
return UserSyncOutcome("failed", "failed to set rotated_at")
return UserSyncOutcome("synced")
detail = check.get("detail") if isinstance(check, dict) else ""
detail = detail or "password check failed"
return UserSyncOutcome("failed", detail)
result = self.sync_user(prepared.username, prepared.mailu_email, prepared.password, wait=True) result = self.sync_user(prepared.username, prepared.mailu_email, prepared.password, wait=True)
result_status = result.get("status") if isinstance(result, dict) else "error" result_status = result.get("status") if isinstance(result, dict) else "error"

View File

@ -163,6 +163,7 @@ class Settings:
vaultwarden_admin_rate_limit_backoff_sec: float vaultwarden_admin_rate_limit_backoff_sec: float
vaultwarden_retry_cooldown_sec: float vaultwarden_retry_cooldown_sec: float
vaultwarden_failure_bailout: int vaultwarden_failure_bailout: int
vaultwarden_invite_refresh_sec: float
smtp_host: str smtp_host: str
smtp_port: int smtp_port: int
@ -420,6 +421,7 @@ class Settings:
"vaultwarden_admin_rate_limit_backoff_sec": _env_float("VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC", 600.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_retry_cooldown_sec": _env_float("VAULTWARDEN_RETRY_COOLDOWN_SEC", 1800.0),
"vaultwarden_failure_bailout": _env_int("VAULTWARDEN_FAILURE_BAILOUT", 2), "vaultwarden_failure_bailout": _env_int("VAULTWARDEN_FAILURE_BAILOUT", 2),
"vaultwarden_invite_refresh_sec": _env_float("VAULTWARDEN_INVITE_REFRESH_SEC", 86400.0),
} }
@classmethod @classmethod
@ -429,7 +431,7 @@ class Settings:
"nextcloud_sync_cron": _env("ARIADNE_SCHEDULE_NEXTCLOUD_SYNC", "0 5 * * *"), "nextcloud_sync_cron": _env("ARIADNE_SCHEDULE_NEXTCLOUD_SYNC", "0 5 * * *"),
"nextcloud_cron": _env("ARIADNE_SCHEDULE_NEXTCLOUD_CRON", "*/5 * * * *"), "nextcloud_cron": _env("ARIADNE_SCHEDULE_NEXTCLOUD_CRON", "*/5 * * * *"),
"nextcloud_maintenance_cron": _env("ARIADNE_SCHEDULE_NEXTCLOUD_MAINTENANCE", "30 4 * * *"), "nextcloud_maintenance_cron": _env("ARIADNE_SCHEDULE_NEXTCLOUD_MAINTENANCE", "30 4 * * *"),
"vaultwarden_sync_cron": _env("ARIADNE_SCHEDULE_VAULTWARDEN_SYNC", "*/15 * * * *"), "vaultwarden_sync_cron": _env("ARIADNE_SCHEDULE_VAULTWARDEN_SYNC", "0 * * * *"),
"wger_user_sync_cron": _env("ARIADNE_SCHEDULE_WGER_USER_SYNC", "0 5 * * *"), "wger_user_sync_cron": _env("ARIADNE_SCHEDULE_WGER_USER_SYNC", "0 5 * * *"),
"wger_admin_cron": _env("ARIADNE_SCHEDULE_WGER_ADMIN", "15 3 * * *"), "wger_admin_cron": _env("ARIADNE_SCHEDULE_WGER_ADMIN", "15 3 * * *"),
"firefly_user_sync_cron": _env("ARIADNE_SCHEDULE_FIREFLY_USER_SYNC", "0 6 * * *"), "firefly_user_sync_cron": _env("ARIADNE_SCHEDULE_FIREFLY_USER_SYNC", "0 6 * * *"),
@ -437,9 +439,9 @@ class Settings:
"pod_cleaner_cron": _env("ARIADNE_SCHEDULE_POD_CLEANER", "0 * * * *"), "pod_cleaner_cron": _env("ARIADNE_SCHEDULE_POD_CLEANER", "0 * * * *"),
"opensearch_prune_cron": _env("ARIADNE_SCHEDULE_OPENSEARCH_PRUNE", "23 3 * * *"), "opensearch_prune_cron": _env("ARIADNE_SCHEDULE_OPENSEARCH_PRUNE", "23 3 * * *"),
"image_sweeper_cron": _env("ARIADNE_SCHEDULE_IMAGE_SWEEPER", "30 4 * * 0"), "image_sweeper_cron": _env("ARIADNE_SCHEDULE_IMAGE_SWEEPER", "30 4 * * 0"),
"vault_k8s_auth_cron": _env("ARIADNE_SCHEDULE_VAULT_K8S_AUTH", "*/15 * * * *"), "vault_k8s_auth_cron": _env("ARIADNE_SCHEDULE_VAULT_K8S_AUTH", "0 * * * *"),
"vault_oidc_cron": _env("ARIADNE_SCHEDULE_VAULT_OIDC", "*/15 * * * *"), "vault_oidc_cron": _env("ARIADNE_SCHEDULE_VAULT_OIDC", "0 * * * *"),
"comms_guest_name_cron": _env("ARIADNE_SCHEDULE_COMMS_GUEST_NAME", "*/1 * * * *"), "comms_guest_name_cron": _env("ARIADNE_SCHEDULE_COMMS_GUEST_NAME", "*/5 * * * *"),
"comms_pin_invite_cron": _env("ARIADNE_SCHEDULE_COMMS_PIN_INVITE", "*/30 * * * *"), "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_reset_room_cron": _env("ARIADNE_SCHEDULE_COMMS_RESET_ROOM", "0 0 1 1 *"),
"comms_seed_room_cron": _env("ARIADNE_SCHEDULE_COMMS_SEED_ROOM", "*/10 * * * *"), "comms_seed_room_cron": _env("ARIADNE_SCHEDULE_COMMS_SEED_ROOM", "*/10 * * * *"),

View File

@ -186,6 +186,70 @@ def test_wger_sync_users(monkeypatch) -> None:
assert any(key == "wger_password_updated_at" for _user, key, _value in calls) assert any(key == "wger_password_updated_at" for _user, key, _value in calls)
def test_wger_sync_marks_rotated(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": {
"mailu_email": ["alice@bstein.dev"],
"wger_password": ["pw"],
"wger_password_updated_at": ["2025-01-01T00:00:00Z"],
},
}
]
def get_user(self, user_id: str):
return {
"id": user_id,
"username": "alice",
"attributes": {
"mailu_email": ["alice@bstein.dev"],
"wger_password": ["pw"],
"wger_password_updated_at": ["2025-01-01T00:00:00Z"],
},
}
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",
)
def fake_check(self, *_args, **_kwargs):
return {"status": "mismatch", "detail": "mismatch"}
monkeypatch.setattr(WgerService, "check_password", fake_check)
monkeypatch.setattr(WgerService, "sync_user", lambda *_args, **_kwargs: {"status": "ok"})
svc = WgerService()
result = svc.sync_users()
assert result["status"] == "ok"
assert any(key == "wger_password_rotated_at" for _user, key, _value in calls)
def test_firefly_sync_user_exec(monkeypatch) -> None: def test_firefly_sync_user_exec(monkeypatch) -> None:
dummy = types.SimpleNamespace( dummy = types.SimpleNamespace(
firefly_namespace="finance", firefly_namespace="finance",
@ -330,6 +394,70 @@ def test_firefly_sync_users(monkeypatch) -> None:
assert any(key == "firefly_password_updated_at" for _user, key, _value in calls) assert any(key == "firefly_password_updated_at" for _user, key, _value in calls)
def test_firefly_sync_marks_rotated(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.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": {
"mailu_email": ["alice@bstein.dev"],
"firefly_password": ["pw"],
"firefly_password_updated_at": ["2025-01-01T00:00:00Z"],
},
}
]
def get_user(self, user_id: str):
return {
"id": user_id,
"username": "alice",
"attributes": {
"mailu_email": ["alice@bstein.dev"],
"firefly_password": ["pw"],
"firefly_password_updated_at": ["2025-01-01T00:00:00Z"],
},
}
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",
)
def fake_check(self, *_args, **_kwargs):
return {"status": "mismatch", "detail": "mismatch"}
monkeypatch.setattr(FireflyService, "check_password", fake_check)
monkeypatch.setattr(FireflyService, "sync_user", lambda *_args, **_kwargs: {"status": "ok"})
svc = FireflyService()
result = svc.sync_users()
assert result["status"] == "ok"
assert any(key == "firefly_password_rotated_at" for _user, key, _value in calls)
def test_mailu_sync_updates_attrs(monkeypatch) -> None: def test_mailu_sync_updates_attrs(monkeypatch) -> None:
dummy_settings = types.SimpleNamespace( dummy_settings = types.SimpleNamespace(
mailu_domain="bstein.dev", mailu_domain="bstein.dev",

View File

@ -97,6 +97,7 @@ def test_vaultwarden_sync_respects_retry_cooldown(monkeypatch) -> None:
mailu_domain="bstein.dev", mailu_domain="bstein.dev",
vaultwarden_retry_cooldown_sec=9999, vaultwarden_retry_cooldown_sec=9999,
vaultwarden_failure_bailout=2, vaultwarden_failure_bailout=2,
vaultwarden_invite_refresh_sec=9999,
) )
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings) monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@ -137,6 +138,7 @@ def test_vaultwarden_sync_bails_after_failures(monkeypatch) -> None:
mailu_domain="bstein.dev", mailu_domain="bstein.dev",
vaultwarden_retry_cooldown_sec=0, vaultwarden_retry_cooldown_sec=0,
vaultwarden_failure_bailout=1, vaultwarden_failure_bailout=1,
vaultwarden_invite_refresh_sec=0,
) )
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings) monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
dummy = DummyAdmin( dummy = DummyAdmin(
@ -162,6 +164,7 @@ def test_vaultwarden_sync_uses_keycloak_email(monkeypatch) -> None:
mailu_domain="bstein.dev", mailu_domain="bstein.dev",
vaultwarden_retry_cooldown_sec=0, vaultwarden_retry_cooldown_sec=0,
vaultwarden_failure_bailout=2, vaultwarden_failure_bailout=2,
vaultwarden_invite_refresh_sec=0,
) )
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings) monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
dummy = DummyAdmin( dummy = DummyAdmin(
@ -265,6 +268,7 @@ def test_vaultwarden_sync_sets_synced_at_for_invited(monkeypatch) -> None:
mailu_domain="bstein.dev", mailu_domain="bstein.dev",
vaultwarden_retry_cooldown_sec=0, vaultwarden_retry_cooldown_sec=0,
vaultwarden_failure_bailout=2, vaultwarden_failure_bailout=2,
vaultwarden_invite_refresh_sec=0,
) )
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings) monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
dummy = DummyAdmin( dummy = DummyAdmin(
@ -284,6 +288,7 @@ def test_vaultwarden_sync_skips_disabled_and_service_accounts(monkeypatch) -> No
mailu_domain="bstein.dev", mailu_domain="bstein.dev",
vaultwarden_retry_cooldown_sec=0, vaultwarden_retry_cooldown_sec=0,
vaultwarden_failure_bailout=2, vaultwarden_failure_bailout=2,
vaultwarden_invite_refresh_sec=0,
) )
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings) monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
dummy = DummyAdmin( dummy = DummyAdmin(
@ -305,6 +310,7 @@ def test_vaultwarden_sync_get_user_failure(monkeypatch) -> None:
mailu_domain="bstein.dev", mailu_domain="bstein.dev",
vaultwarden_retry_cooldown_sec=0, vaultwarden_retry_cooldown_sec=0,
vaultwarden_failure_bailout=2, vaultwarden_failure_bailout=2,
vaultwarden_invite_refresh_sec=0,
) )
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings) monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
@ -328,6 +334,7 @@ def test_vaultwarden_sync_invited_attribute_failure(monkeypatch) -> None:
mailu_domain="bstein.dev", mailu_domain="bstein.dev",
vaultwarden_retry_cooldown_sec=0, vaultwarden_retry_cooldown_sec=0,
vaultwarden_failure_bailout=2, vaultwarden_failure_bailout=2,
vaultwarden_invite_refresh_sec=0,
) )
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings) monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)