From 418a7b70991a5e6f6ca34d614307f1556081333e Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 3 Apr 2026 02:14:01 +0800 Subject: [PATCH] Sync site-role assignments to Keycloak group role mappings --- backend/app/api/admin_catalog.py | 80 ++++++++++++++++++++- backend/app/repositories/site_roles_repo.py | 10 +-- backend/app/services/idp_admin_service.py | 59 +++++++++++++++ docs/ARCHITECTURE.md | 1 + docs/BACKEND_TASKPLAN.md | 1 + 5 files changed, 145 insertions(+), 6 deletions(-) diff --git a/backend/app/api/admin_catalog.py b/backend/app/api/admin_catalog.py index ce1ac42..d821dbc 100644 --- a/backend/app/api/admin_catalog.py +++ b/backend/app/api/admin_catalog.py @@ -129,6 +129,31 @@ def _site_group_name(display_name: str, site_key: str) -> str: return normalized +def _sync_site_client_roles( + *, + idp: ProviderAdminService, + site, + site_role_rows, + provider_client_ids: set[str], +) -> None: + if not site.provider_group_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"site_provider_group_missing:{site.site_key}") + + role_names_by_client: dict[str, list[str]] = {} + for _, role, system in site_role_rows: + provider_client_id = str(system.name or "").strip() + if not provider_client_id: + continue + role_names_by_client.setdefault(provider_client_id, []).append(role.name) + + for provider_client_id in sorted(provider_client_ids): + idp.set_group_client_roles( + group_id=site.provider_group_id, + provider_client_id=provider_client_id, + role_names=role_names_by_client.get(provider_client_id, []), + ) + + @router.get("/companies", response_model=ListResponse) def list_companies( db: Session = Depends(get_db), @@ -597,11 +622,15 @@ def assign_site_roles(site_key: str, payload: SiteRoleAssignRequest, db: Session sites_repo = SitesRepository(db) roles_repo = RolesRepository(db) site_roles_repo = SiteRolesRepository(db) + idp = ProviderAdminService(get_settings()) site = sites_repo.get_by_key(site_key) if not site: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") + current_rows = site_roles_repo.list_site_role_rows(site.id) + current_client_ids = {str(system.name or "").strip() for _, _, system in current_rows if str(system.name or "").strip()} + role_ids: list[str] = [] for role_key in list(dict.fromkeys(payload.role_keys)): role = roles_repo.get_by_key(role_key) @@ -609,7 +638,23 @@ def assign_site_roles(site_key: str, payload: SiteRoleAssignRequest, db: Session raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"role_not_found:{role_key}") role_ids.append(role.id) - site_roles_repo.set_site_roles(site_id=site.id, role_ids=role_ids) + site_roles_repo.set_site_roles(site_id=site.id, role_ids=role_ids, commit=False) + updated_rows = site_roles_repo.list_site_role_rows(site.id) + updated_client_ids = {str(system.name or "").strip() for _, _, system in updated_rows if str(system.name or "").strip()} + clients_to_sync = current_client_ids | updated_client_ids + + try: + _sync_site_client_roles( + idp=idp, + site=site, + site_role_rows=updated_rows, + provider_client_ids=clients_to_sync, + ) + db.commit() + except Exception: + db.rollback() + raise + return list_site_roles(site_key=site_key, db=db) @@ -650,12 +695,24 @@ def list_role_sites(role_key: str, db: Session = Depends(get_db)) -> RoleSitesRe def assign_role_sites(role_key: str, payload: UserSiteAssignRequest, db: Session = Depends(get_db)) -> RoleSitesResponse: roles_repo = RolesRepository(db) sites_repo = SitesRepository(db) + systems_repo = SystemsRepository(db) site_roles_repo = SiteRolesRepository(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") + system = systems_repo.get_by_id(role.system_id) + if not system: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") + provider_client_id = str(system.name or "").strip() + if not provider_client_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"provider_client_id_missing:{system.system_key}") + + previous_rows = site_roles_repo.list_role_site_rows(role.id) + previous_site_ids = {site.id for _, site in previous_rows} + site_ids: list[str] = [] for site_key in list(dict.fromkeys(payload.site_keys)): site = sites_repo.get_by_key(site_key) @@ -663,7 +720,26 @@ def assign_role_sites(role_key: str, payload: UserSiteAssignRequest, db: Session raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"site_not_found:{site_key}") site_ids.append(site.id) - site_roles_repo.set_role_sites(role_id=role.id, site_ids=site_ids) + site_roles_repo.set_role_sites(role_id=role.id, site_ids=site_ids, commit=False) + + affected_site_ids = previous_site_ids | set(site_ids) + try: + for site_id in affected_site_ids: + site = sites_repo.get_by_id(site_id) + if not site: + continue + site_rows = site_roles_repo.list_site_role_rows(site.id) + _sync_site_client_roles( + idp=idp, + site=site, + site_role_rows=site_rows, + provider_client_ids={provider_client_id}, + ) + db.commit() + except Exception: + db.rollback() + raise + return list_role_sites(role_key=role_key, db=db) diff --git a/backend/app/repositories/site_roles_repo.py b/backend/app/repositories/site_roles_repo.py index 689138e..aa015af 100644 --- a/backend/app/repositories/site_roles_repo.py +++ b/backend/app/repositories/site_roles_repo.py @@ -30,14 +30,16 @@ class SiteRolesRepository: ) return list(self.db.execute(stmt).all()) - def set_site_roles(self, *, site_id: str, role_ids: list[str]) -> None: + def set_site_roles(self, *, site_id: str, role_ids: list[str], commit: bool = True) -> None: self.db.execute(delete(SiteRole).where(SiteRole.site_id == site_id)) for role_id in role_ids: self.db.add(SiteRole(site_id=site_id, role_id=role_id)) - self.db.commit() + if commit: + self.db.commit() - def set_role_sites(self, *, role_id: str, site_ids: list[str]) -> None: + def set_role_sites(self, *, role_id: str, site_ids: list[str], commit: bool = True) -> None: self.db.execute(delete(SiteRole).where(SiteRole.role_id == role_id)) for site_id in site_ids: self.db.add(SiteRole(site_id=site_id, role_id=role_id)) - self.db.commit() + if commit: + self.db.commit() diff --git a/backend/app/services/idp_admin_service.py b/backend/app/services/idp_admin_service.py index 51e045a..0f37e77 100644 --- a/backend/app/services/idp_admin_service.py +++ b/backend/app/services/idp_admin_service.py @@ -413,6 +413,65 @@ class ProviderAdminService: return client_uuid raise HTTPException(status_code=404, detail="provider_client_not_found") + def _get_client_role_representation(self, client: httpx.Client, *, client_uuid: str, role_name: str) -> dict: + resp = client.get(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}") + if resp.status_code == 404: + raise HTTPException(status_code=404, detail=f"provider_role_not_found:{role_name}") + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_lookup_failed") + payload = resp.json() if resp.content else {} + if not isinstance(payload, dict): + raise HTTPException(status_code=502, detail="idp_lookup_failed") + return payload + + def set_group_client_roles(self, *, group_id: str, provider_client_id: str, role_names: list[str]) -> None: + if not group_id: + raise HTTPException(status_code=400, detail="provider_group_id_required") + + desired_names = [name.strip() for name in role_names if isinstance(name, str) and name.strip()] + desired_name_set = set(desired_names) + + with self._client() as client: + client_uuid = self._resolve_client_uuid(client, provider_client_id) + + current_resp = client.get(f"/admin/realms/{self.realm}/groups/{group_id}/role-mappings/clients/{client_uuid}") + if current_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_group_role_mapping_lookup_failed") + current_payload = current_resp.json() if current_resp.content else [] + current_rows = current_payload if isinstance(current_payload, list) else [] + current_map: dict[str, dict] = {} + for row in current_rows: + if not isinstance(row, dict): + continue + name = str(row.get("name", "")).strip() + if name: + current_map[name] = row + + to_add_names = sorted(desired_name_set - set(current_map.keys())) + to_remove_names = sorted(set(current_map.keys()) - desired_name_set) + + if to_add_names: + add_payload = [ + self._get_client_role_representation(client, client_uuid=client_uuid, role_name=role_name) + for role_name in to_add_names + ] + add_resp = client.post( + f"/admin/realms/{self.realm}/groups/{group_id}/role-mappings/clients/{client_uuid}", + json=add_payload, + ) + if add_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_group_role_mapping_add_failed") + + if to_remove_names: + remove_payload = [current_map[name] for name in to_remove_names] + remove_resp = client.request( + "DELETE", + f"/admin/realms/{self.realm}/groups/{group_id}/role-mappings/clients/{client_uuid}", + json=remove_payload, + ) + if remove_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_group_role_mapping_remove_failed") + def ensure_client_role( self, *, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ccfced4..4682d53 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,6 +28,7 @@ - `site_roles` 代表某 Site 擁有的 Keycloak role 集合。 - 同步策略改為手動觸發:不在列表讀取 (`R`) 時自動同步。 - 補齊策略:僅在手動同步按鈕(`POST /admin/sync/from-provider`)或 CUD 流程時同步。 +- 站台角色指派(`PUT /admin/sites/{site_key}/roles`、`PUT /admin/roles/{role_key}/sites`)會即時同步到 Keycloak Group Role Mapping。 - 使用者加入 Site 時,透過同步邏輯使其在 IdP 端取得對應角色能力。 ## 後台安全線 diff --git a/docs/BACKEND_TASKPLAN.md b/docs/BACKEND_TASKPLAN.md index 2ad5940..20f684c 100644 --- a/docs/BACKEND_TASKPLAN.md +++ b/docs/BACKEND_TASKPLAN.md @@ -22,3 +22,4 @@ - [x] Keycloak -> DB 補齊同步(公司/站台/系統/角色/使用者)。 - [x] 系統改為 Keycloak 唯一來源(後台停用 system CRUD)。 - [x] Role CRUD 同步 Provider Client Role(新增/修改/刪除會同步到 Provider)。 +- [x] Site/Role 關聯指派同步 Keycloak Group Role Mapping(雙向指派入口皆同步)。