ariadne: add password-rotation checks + tune schedules
This commit is contained in:
parent
8578524e56
commit
b45911c50e
@ -19,6 +19,7 @@ from .mailu import mailu
|
||||
HTTP_OK = 200
|
||||
FIREFLY_PASSWORD_ATTR = "firefly_password"
|
||||
FIREFLY_PASSWORD_UPDATED_ATTR = "firefly_password_updated_at"
|
||||
FIREFLY_PASSWORD_ROTATED_ATTR = "firefly_password_rotated_at"
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@ -141,11 +142,109 @@ _FIREFLY_SYNC_SCRIPT = textwrap.dedent(
|
||||
"""
|
||||
).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:
|
||||
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)
|
||||
class FireflySyncSummary:
|
||||
processed: int
|
||||
@ -194,6 +293,7 @@ class FireflySyncInput:
|
||||
password: str
|
||||
password_generated: bool
|
||||
updated_at: str
|
||||
rotated_at: str
|
||||
|
||||
|
||||
def _extract_attr(attrs: Any, key: str) -> str:
|
||||
@ -264,6 +364,15 @@ def _set_firefly_updated_at(username: str) -> bool:
|
||||
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]:
|
||||
username = user.get("username") if isinstance(user.get("username"), str) else ""
|
||||
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")
|
||||
|
||||
updated_at = _extract_attr(attrs, FIREFLY_PASSWORD_UPDATED_ATTR)
|
||||
rotated_at = _extract_attr(attrs, FIREFLY_PASSWORD_ROTATED_ATTR)
|
||||
return FireflySyncInput(
|
||||
username=identity.username,
|
||||
mailu_email=mailu_email,
|
||||
password=password,
|
||||
password_generated=password_generated,
|
||||
updated_at=updated_at,
|
||||
rotated_at=rotated_at,
|
||||
)
|
||||
|
||||
|
||||
@ -383,6 +494,39 @@ class FireflyService:
|
||||
output = (result.stdout or result.stderr).strip()
|
||||
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]:
|
||||
if not settings.firefly_cron_token:
|
||||
raise RuntimeError("firefly cron token missing")
|
||||
@ -401,7 +545,19 @@ class FireflyService:
|
||||
if isinstance(prepared, UserSyncOutcome):
|
||||
return prepared
|
||||
if _should_skip_sync(prepared.password_generated, prepared.updated_at):
|
||||
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_status = result.get("status") if isinstance(result, dict) else "error"
|
||||
|
||||
@ -54,21 +54,24 @@ class VaultwardenService:
|
||||
return [url for url in urls if url]
|
||||
|
||||
@staticmethod
|
||||
def _already_present(resp: httpx.Response) -> bool:
|
||||
def _invite_conflict_status(resp: httpx.Response) -> str | None:
|
||||
try:
|
||||
body = resp.text or ""
|
||||
except Exception:
|
||||
body = ""
|
||||
lowered = body.lower()
|
||||
return any(
|
||||
if "already invited" in lowered:
|
||||
return "invited"
|
||||
if any(
|
||||
marker in lowered
|
||||
for marker in (
|
||||
"already invited",
|
||||
"already exists",
|
||||
"already registered",
|
||||
"user already exists",
|
||||
)
|
||||
)
|
||||
):
|
||||
return "already_present"
|
||||
return None
|
||||
|
||||
def _invite_via(self, base_url: str, email: str) -> VaultwardenInvite | None:
|
||||
if not base_url:
|
||||
@ -81,10 +84,16 @@ class VaultwardenService:
|
||||
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):
|
||||
elif resp.status_code in {HTTP_BAD_REQUEST, HTTP_CONFLICT}:
|
||||
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:
|
||||
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():
|
||||
|
||||
@ -15,6 +15,7 @@ from .vaultwarden import vaultwarden
|
||||
VAULTWARDEN_EMAIL_ATTR = "vaultwarden_email"
|
||||
VAULTWARDEN_STATUS_ATTR = "vaultwarden_status"
|
||||
VAULTWARDEN_SYNCED_AT_ATTR = "vaultwarden_synced_at"
|
||||
VAULTWARDEN_MASTER_ATTR = "vaultwarden_master_password_set_at"
|
||||
|
||||
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 {}
|
||||
status = _extract_attr(attrs, VAULTWARDEN_STATUS_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)
|
||||
if not status:
|
||||
return True
|
||||
if status in {"invited", "already_present"} and not synced_at:
|
||||
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):
|
||||
return True
|
||||
return False
|
||||
@ -183,6 +189,19 @@ def _set_sync_status(username: str, status: str) -> None:
|
||||
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:
|
||||
try:
|
||||
_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
|
||||
|
||||
|
||||
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(
|
||||
username: str,
|
||||
current_status: str,
|
||||
current_synced_at: str,
|
||||
current_synced_ts: float | None,
|
||||
full_user: dict[str, Any],
|
||||
counters: VaultwardenSyncCounters,
|
||||
) -> bool:
|
||||
if current_status not in {"invited", "already_present"}:
|
||||
return False
|
||||
if current_status == "already_present":
|
||||
if not current_synced_at:
|
||||
_set_sync_status(username, current_status)
|
||||
_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(
|
||||
@ -227,7 +262,14 @@ def _sync_user(
|
||||
counters.skipped += 1
|
||||
else:
|
||||
_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
|
||||
else:
|
||||
counters.processed += 1
|
||||
@ -239,6 +281,8 @@ def _sync_user(
|
||||
else:
|
||||
counters.failures += 1
|
||||
_set_sync_status(username, result.status)
|
||||
if result.status == "already_present":
|
||||
_set_master_password_set(username, full_user)
|
||||
return status, ok
|
||||
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ from .mailu import mailu
|
||||
|
||||
WGER_PASSWORD_ATTR = "wger_password"
|
||||
WGER_PASSWORD_UPDATED_ATTR = "wger_password_updated_at"
|
||||
WGER_PASSWORD_ROTATED_ATTR = "wger_password_rotated_at"
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@ -142,12 +143,67 @@ _WGER_SYNC_SCRIPT = textwrap.dedent(
|
||||
"""
|
||||
).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:
|
||||
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"
|
||||
|
||||
|
||||
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)
|
||||
class WgerSyncSummary:
|
||||
processed: int
|
||||
@ -196,6 +252,7 @@ class WgerSyncInput:
|
||||
password: str
|
||||
password_generated: bool
|
||||
updated_at: str
|
||||
rotated_at: str
|
||||
|
||||
|
||||
def _extract_attr(attrs: Any, key: str) -> str:
|
||||
@ -266,6 +323,15 @@ def _set_wger_updated_at(username: str) -> bool:
|
||||
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]:
|
||||
username = user.get("username") if isinstance(user.get("username"), str) else ""
|
||||
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")
|
||||
|
||||
updated_at = _extract_attr(attrs, WGER_PASSWORD_UPDATED_ATTR)
|
||||
rotated_at = _extract_attr(attrs, WGER_PASSWORD_ROTATED_ATTR)
|
||||
return WgerSyncInput(
|
||||
username=identity.username,
|
||||
mailu_email=mailu_email,
|
||||
password=password,
|
||||
password_generated=password_generated,
|
||||
updated_at=updated_at,
|
||||
rotated_at=rotated_at,
|
||||
)
|
||||
|
||||
|
||||
@ -386,6 +454,39 @@ class WgerService:
|
||||
output = (result.stdout or result.stderr).strip()
|
||||
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]:
|
||||
if not settings.wger_namespace:
|
||||
raise RuntimeError("wger admin sync not configured")
|
||||
@ -415,7 +516,19 @@ class WgerService:
|
||||
if isinstance(prepared, UserSyncOutcome):
|
||||
return prepared
|
||||
if _should_skip_sync(prepared.password_generated, prepared.updated_at):
|
||||
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_status = result.get("status") if isinstance(result, dict) else "error"
|
||||
|
||||
@ -163,6 +163,7 @@ class Settings:
|
||||
vaultwarden_admin_rate_limit_backoff_sec: float
|
||||
vaultwarden_retry_cooldown_sec: float
|
||||
vaultwarden_failure_bailout: int
|
||||
vaultwarden_invite_refresh_sec: float
|
||||
|
||||
smtp_host: str
|
||||
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_retry_cooldown_sec": _env_float("VAULTWARDEN_RETRY_COOLDOWN_SEC", 1800.0),
|
||||
"vaultwarden_failure_bailout": _env_int("VAULTWARDEN_FAILURE_BAILOUT", 2),
|
||||
"vaultwarden_invite_refresh_sec": _env_float("VAULTWARDEN_INVITE_REFRESH_SEC", 86400.0),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@ -429,7 +431,7 @@ class Settings:
|
||||
"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 * * * *"),
|
||||
"vaultwarden_sync_cron": _env("ARIADNE_SCHEDULE_VAULTWARDEN_SYNC", "0 * * * *"),
|
||||
"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 * * *"),
|
||||
@ -437,9 +439,9 @@ class Settings:
|
||||
"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 * * * *"),
|
||||
"vault_k8s_auth_cron": _env("ARIADNE_SCHEDULE_VAULT_K8S_AUTH", "0 * * * *"),
|
||||
"vault_oidc_cron": _env("ARIADNE_SCHEDULE_VAULT_OIDC", "0 * * * *"),
|
||||
"comms_guest_name_cron": _env("ARIADNE_SCHEDULE_COMMS_GUEST_NAME", "*/5 * * * *"),
|
||||
"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 * * * *"),
|
||||
|
||||
@ -186,6 +186,70 @@ def test_wger_sync_users(monkeypatch) -> None:
|
||||
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:
|
||||
dummy = types.SimpleNamespace(
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
dummy_settings = types.SimpleNamespace(
|
||||
mailu_domain="bstein.dev",
|
||||
|
||||
@ -97,6 +97,7 @@ def test_vaultwarden_sync_respects_retry_cooldown(monkeypatch) -> None:
|
||||
mailu_domain="bstein.dev",
|
||||
vaultwarden_retry_cooldown_sec=9999,
|
||||
vaultwarden_failure_bailout=2,
|
||||
vaultwarden_invite_refresh_sec=9999,
|
||||
)
|
||||
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
|
||||
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",
|
||||
vaultwarden_retry_cooldown_sec=0,
|
||||
vaultwarden_failure_bailout=1,
|
||||
vaultwarden_invite_refresh_sec=0,
|
||||
)
|
||||
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
|
||||
dummy = DummyAdmin(
|
||||
@ -162,6 +164,7 @@ def test_vaultwarden_sync_uses_keycloak_email(monkeypatch) -> None:
|
||||
mailu_domain="bstein.dev",
|
||||
vaultwarden_retry_cooldown_sec=0,
|
||||
vaultwarden_failure_bailout=2,
|
||||
vaultwarden_invite_refresh_sec=0,
|
||||
)
|
||||
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
|
||||
dummy = DummyAdmin(
|
||||
@ -265,6 +268,7 @@ def test_vaultwarden_sync_sets_synced_at_for_invited(monkeypatch) -> None:
|
||||
mailu_domain="bstein.dev",
|
||||
vaultwarden_retry_cooldown_sec=0,
|
||||
vaultwarden_failure_bailout=2,
|
||||
vaultwarden_invite_refresh_sec=0,
|
||||
)
|
||||
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
|
||||
dummy = DummyAdmin(
|
||||
@ -284,6 +288,7 @@ def test_vaultwarden_sync_skips_disabled_and_service_accounts(monkeypatch) -> No
|
||||
mailu_domain="bstein.dev",
|
||||
vaultwarden_retry_cooldown_sec=0,
|
||||
vaultwarden_failure_bailout=2,
|
||||
vaultwarden_invite_refresh_sec=0,
|
||||
)
|
||||
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
|
||||
dummy = DummyAdmin(
|
||||
@ -305,6 +310,7 @@ def test_vaultwarden_sync_get_user_failure(monkeypatch) -> None:
|
||||
mailu_domain="bstein.dev",
|
||||
vaultwarden_retry_cooldown_sec=0,
|
||||
vaultwarden_failure_bailout=2,
|
||||
vaultwarden_invite_refresh_sec=0,
|
||||
)
|
||||
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
|
||||
|
||||
@ -328,6 +334,7 @@ def test_vaultwarden_sync_invited_attribute_failure(monkeypatch) -> None:
|
||||
mailu_domain="bstein.dev",
|
||||
vaultwarden_retry_cooldown_sec=0,
|
||||
vaultwarden_failure_bailout=2,
|
||||
vaultwarden_invite_refresh_sec=0,
|
||||
)
|
||||
monkeypatch.setattr(vaultwarden_sync, "settings", dummy_settings)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user