diff --git a/backend/atlas_portal/keycloak.py b/backend/atlas_portal/keycloak.py index 0df0b97..e0f204f 100644 --- a/backend/atlas_portal/keycloak.py +++ b/backend/atlas_portal/keycloak.py @@ -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) diff --git a/backend/atlas_portal/provisioning.py b/backend/atlas_portal/provisioning.py index 4c740c8..6d0838d 100644 --- a/backend/atlas_portal/provisioning.py +++ b/backend/atlas_portal/provisioning.py @@ -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) diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 1f2687c..6a764c7 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -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( """