From 49949498e0c7dff34facd4f576a64a74323d06b8 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 3 Apr 2026 01:17:13 +0800 Subject: [PATCH] fix: sync role CRUD with provider client roles --- app/api/admin_catalog.py | 60 ++++++++++++++- app/services/idp_admin_service.py | 122 ++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 3 deletions(-) diff --git a/app/api/admin_catalog.py b/app/api/admin_catalog.py index d8fa2e6..1708d8c 100644 --- a/app/api/admin_catalog.py +++ b/app/api/admin_catalog.py @@ -424,10 +424,19 @@ def list_roles( def create_role(payload: RoleCreateRequest, db: Session = Depends(get_db)) -> RoleItem: systems_repo = SystemsRepository(db) roles_repo = RolesRepository(db) + idp = ProviderAdminService(get_settings()) system = systems_repo.get_by_key(payload.system_key) if not system: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") + if not system.provider_client_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_provider_client_id_missing") + + idp.ensure_client_role( + provider_client_id=system.provider_client_id, + provider_role_name=payload.provider_role_name, + description=payload.description, + ) role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None) try: @@ -459,17 +468,49 @@ def create_role(payload: RoleCreateRequest, db: Session = Depends(get_db)) -> Ro def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends(get_db)) -> RoleItem: systems_repo = SystemsRepository(db) roles_repo = RolesRepository(db) + idp = ProviderAdminService(get_settings()) role = roles_repo.get_by_key(role_key) if not role: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found") + old_system = systems_repo.get_by_id(role.system_id) + if not old_system: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="system_reference_missing") + if not old_system.provider_client_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_provider_client_id_missing") + + target_system = old_system system_id = None if payload.system_key: system = systems_repo.get_by_key(payload.system_key) if not system: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") system_id = system.id + target_system = system + if not target_system.provider_client_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_provider_client_id_missing") + + next_provider_role_name = payload.provider_role_name if payload.provider_role_name is not None else role.provider_role_name + next_description = payload.description if payload.description is not None else role.description + + if target_system.id != old_system.id: + idp.ensure_client_role( + provider_client_id=target_system.provider_client_id, + provider_role_name=next_provider_role_name, + description=next_description, + ) + idp.delete_client_role( + provider_client_id=old_system.provider_client_id, + provider_role_name=role.provider_role_name, + ) + else: + idp.update_client_role( + provider_client_id=target_system.provider_client_id, + old_provider_role_name=role.provider_role_name, + new_provider_role_name=next_provider_role_name, + description=next_description, + ) try: role = roles_repo.update( @@ -502,11 +543,24 @@ def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends @router.delete("/roles/{role_key}") def delete_role(role_key: str, db: Session = Depends(get_db)) -> dict[str, str]: - repo = RolesRepository(db) - role = repo.get_by_key(role_key) + roles_repo = RolesRepository(db) + systems_repo = SystemsRepository(db) + idp = ProviderAdminService(get_settings()) + + role = roles_repo.get_by_key(role_key) if not role: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found") - repo.delete(role) + system = systems_repo.get_by_id(role.system_id) + if not system: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="system_reference_missing") + if not system.provider_client_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_provider_client_id_missing") + + idp.delete_client_role( + provider_client_id=system.provider_client_id, + provider_role_name=role.provider_role_name, + ) + roles_repo.delete(role) return {"deleted": role_key} diff --git a/app/services/idp_admin_service.py b/app/services/idp_admin_service.py index 097e0e8..51e045a 100644 --- a/app/services/idp_admin_service.py +++ b/app/services/idp_admin_service.py @@ -35,6 +35,12 @@ class ProviderGroupSyncResult: action: str +@dataclass +class ProviderRoleSyncResult: + role_name: str + action: str + + class ProviderAdminService: def __init__(self, settings: Settings) -> None: self.base_url = settings.keycloak_base_url.rstrip("/") @@ -384,3 +390,119 @@ class ProviderAdminService: raise HTTPException(status_code=502, detail="idp_lookup_failed") payload = resp.json() if resp.content else [] return payload if isinstance(payload, list) else [] + + def _resolve_client_uuid(self, client: httpx.Client, provider_client_id: str) -> str: + if not provider_client_id: + raise HTTPException(status_code=400, detail="provider_client_id_required") + # provider_client_id stores Keycloak clientId (not internal UUID). + resp = client.get( + f"/admin/realms/{self.realm}/clients", + params={"clientId": provider_client_id}, + ) + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_lookup_failed") + payload = resp.json() if resp.content else [] + rows = payload if isinstance(payload, list) else [] + for row in rows: + if not isinstance(row, dict): + continue + if str(row.get("clientId", "")).strip() != provider_client_id: + continue + client_uuid = str(row.get("id", "")).strip() + if client_uuid: + return client_uuid + raise HTTPException(status_code=404, detail="provider_client_not_found") + + def ensure_client_role( + self, + *, + provider_client_id: str, + provider_role_name: str, + description: str | None = None, + ) -> ProviderRoleSyncResult: + role_name = provider_role_name.strip() + if not role_name: + raise HTTPException(status_code=400, detail="provider_role_name_required") + + with self._client() as client: + client_uuid = self._resolve_client_uuid(client, provider_client_id) + get_resp = client.get(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}") + if get_resp.status_code == 404: + create_resp = client.post( + f"/admin/realms/{self.realm}/clients/{client_uuid}/roles", + json={"name": role_name, "description": description or ""}, + ) + if create_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_role_create_failed") + return ProviderRoleSyncResult(role_name=role_name, action="created") + if get_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_lookup_failed") + + payload = get_resp.json() if get_resp.content else {} + update_payload = { + "name": role_name, + "description": description if description is not None else payload.get("description", ""), + "attributes": payload.get("attributes") if isinstance(payload, dict) else {}, + } + put_resp = client.put( + f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}", + json=update_payload, + ) + if put_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_role_update_failed") + return ProviderRoleSyncResult(role_name=role_name, action="updated") + + def update_client_role( + self, + *, + provider_client_id: str, + old_provider_role_name: str, + new_provider_role_name: str, + description: str | None = None, + ) -> ProviderRoleSyncResult: + old_name = old_provider_role_name.strip() + new_name = new_provider_role_name.strip() + if not old_name or not new_name: + raise HTTPException(status_code=400, detail="provider_role_name_required") + + with self._client() as client: + client_uuid = self._resolve_client_uuid(client, provider_client_id) + get_resp = client.get(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{old_name}") + if get_resp.status_code == 404: + # If old role missing, create the new one to self-heal drift. + create_resp = client.post( + f"/admin/realms/{self.realm}/clients/{client_uuid}/roles", + json={"name": new_name, "description": description or ""}, + ) + if create_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_role_create_failed") + return ProviderRoleSyncResult(role_name=new_name, action="created") + if get_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_lookup_failed") + + payload = get_resp.json() if get_resp.content else {} + update_payload = { + "name": new_name, + "description": description if description is not None else payload.get("description", ""), + "attributes": payload.get("attributes") if isinstance(payload, dict) else {}, + } + put_resp = client.put( + f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{old_name}", + json=update_payload, + ) + if put_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_role_update_failed") + return ProviderRoleSyncResult(role_name=new_name, action="updated") + + def delete_client_role(self, *, provider_client_id: str, provider_role_name: str) -> ProviderDeleteResult: + role_name = provider_role_name.strip() + if not role_name: + return ProviderDeleteResult(action="not_found") + with self._client() as client: + client_uuid = self._resolve_client_uuid(client, provider_client_id) + resp = client.delete(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}") + if resp.status_code in {204, 404}: + return ProviderDeleteResult(action="deleted" if resp.status_code == 204 else "not_found") + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_role_delete_failed") + return ProviderDeleteResult(action="deleted")