fix(keycloak): preserve profile fields

This commit is contained in:
Brad Stein 2026-01-20 03:58:34 -03:00
parent deb3813c2e
commit fbed11aeed
3 changed files with 53 additions and 7 deletions

View File

@ -55,6 +55,36 @@ class KeycloakAdminClient:
self._expires_at: float = 0.0
self._group_id_cache: dict[str, str] = {}
@staticmethod
def _safe_update_payload(full: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = {}
username = full.get("username")
if isinstance(username, str):
payload["username"] = username
enabled = full.get("enabled")
if isinstance(enabled, bool):
payload["enabled"] = enabled
email = full.get("email")
if isinstance(email, str):
payload["email"] = email
email_verified = full.get("emailVerified")
if isinstance(email_verified, bool):
payload["emailVerified"] = email_verified
first_name = full.get("firstName")
if isinstance(first_name, str):
payload["firstName"] = first_name
last_name = full.get("lastName")
if isinstance(last_name, str):
payload["lastName"] = last_name
actions = full.get("requiredActions")
if isinstance(actions, list):
payload["requiredActions"] = [a for a in actions if isinstance(a, str)]
attrs = full.get("attributes")
payload["attributes"] = attrs if isinstance(attrs, dict) else {}
return payload
def ready(self) -> bool:
return bool(settings.KEYCLOAK_ADMIN_CLIENT_ID and settings.KEYCLOAK_ADMIN_CLIENT_SECRET)
@ -146,6 +176,21 @@ class KeycloakAdminClient:
resp = client.put(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload)
resp.raise_for_status()
def update_user_safe(self, user_id: str, payload: dict[str, Any]) -> None:
full = self.get_user(user_id)
merged = self._safe_update_payload(full)
for key, value in payload.items():
if key == "attributes":
attrs = merged.get("attributes")
if not isinstance(attrs, dict):
attrs = {}
if isinstance(value, dict):
attrs.update(value)
merged["attributes"] = attrs
continue
merged[key] = value
self.update_user(user_id, merged)
def create_user(self, payload: dict[str, Any]) -> str:
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users"
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
@ -175,13 +220,14 @@ class KeycloakAdminClient:
raise RuntimeError("user id missing")
full = self.get_user(user_id)
attrs = full.get("attributes") or {}
payload = self._safe_update_payload(full)
attrs = payload.get("attributes")
if not isinstance(attrs, dict):
attrs = {}
attrs[key] = [value]
# Keycloak rejects PUTs that include read-only fields from the GET payload (400 Bad Request).
# Update only the attributes we intend to change.
self.update_user(user_id, {"attributes": attrs})
payload["attributes"] = attrs
# Keep profile fields intact so required actions don't re-trigger unexpectedly.
self.update_user(user_id, payload)
def get_group_id(self, group_name: str) -> str | None:
cached = self._group_id_cache.get(group_name)

View File

@ -245,7 +245,7 @@ def provision_access_request(request_code: str) -> ProvisionResult:
if isinstance(actions, list) and "CONFIGURE_TOTP" in actions:
# Backfill earlier accounts created when we forced MFA enrollment.
new_actions = [a for a in actions if a != "CONFIGURE_TOTP"]
admin_client().update_user(user_id, {"requiredActions": new_actions})
admin_client().update_user_safe(user_id, {"requiredActions": new_actions})
mailu_from_attr: str | None = None
if isinstance(attrs, dict):
raw_mailu = attrs.get(MAILU_EMAIL_ATTR)

View File

@ -177,7 +177,7 @@ def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> se
# remove it if present.
if "CONFIGURE_TOTP" in required_actions:
try:
admin_client().update_user(
admin_client().update_user_safe(
user_id,
{"requiredActions": [a for a in actions_list if a != "CONFIGURE_TOTP"]},
)
@ -824,7 +824,7 @@ def register(app) -> None:
actions_list = [a for a in actions if isinstance(a, str)]
if "UPDATE_PASSWORD" not in actions_list:
actions_list.append("UPDATE_PASSWORD")
admin_client().update_user(user_id, {"requiredActions": actions_list})
admin_client().update_user_safe(user_id, {"requiredActions": actions_list})
conn.execute(
"""