From 51a733096f96e27c8202a9b20b5fb4df59ee96b9 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 3 Jan 2026 18:18:31 -0300 Subject: [PATCH] vaultwarden: make cred sync idempotent --- scripts/vaultwarden_cred_sync.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/vaultwarden_cred_sync.py b/scripts/vaultwarden_cred_sync.py index 4fb3f8a..3850249 100644 --- a/scripts/vaultwarden_cred_sync.py +++ b/scripts/vaultwarden_cred_sync.py @@ -27,7 +27,9 @@ def _iter_keycloak_users(page_size: int = 200) -> Iterable[dict[str, Any]]: first = 0 while True: headers = client.headers() - params = {"first": str(first), "max": str(page_size)} + # We need attributes for idempotency (vaultwarden_status/vaultwarden_email). Keycloak defaults to a + # brief representation which may omit these. + params = {"first": str(first), "max": str(page_size), "briefRepresentation": "false"} with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as http: resp = http.get(url, params=params, headers=headers) resp.raise_for_status() @@ -79,7 +81,9 @@ def _vaultwarden_email_for_user(user: dict[str, Any]) -> str: if email and email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"): return email - return f"{username}@{settings.MAILU_DOMAIN}" + # Don't guess an internal mailbox address until Mailu sync has run and stored mailu_email. + # This avoids spamming Vaultwarden invites that can never be delivered (unknown recipient). + return "" def _set_user_attribute_if_missing(username: str, user: dict[str, Any], key: str, value: str) -> None: @@ -121,6 +125,7 @@ def main() -> int: skipped += 1 continue + current_status = _extract_attr(user.get("attributes"), VAULTWARDEN_STATUS_ATTR) email = _vaultwarden_email_for_user(user) if not email: print(f"skip {username}: missing email", file=sys.stderr) @@ -132,6 +137,12 @@ def main() -> int: except Exception: pass + # If we've already successfully invited or confirmed presence, do not re-invite on every cron run. + # Vaultwarden returns 409 for "already exists", which is idempotent but noisy and can trigger rate limits. + if current_status in {"invited", "already_present"}: + skipped += 1 + continue + processed += 1 result = invite_user(email) if result.ok: