fix: sync role CRUD with provider client roles
This commit is contained in:
@@ -424,10 +424,19 @@ def list_roles(
|
|||||||
def create_role(payload: RoleCreateRequest, db: Session = Depends(get_db)) -> RoleItem:
|
def create_role(payload: RoleCreateRequest, db: Session = Depends(get_db)) -> RoleItem:
|
||||||
systems_repo = SystemsRepository(db)
|
systems_repo = SystemsRepository(db)
|
||||||
roles_repo = RolesRepository(db)
|
roles_repo = RolesRepository(db)
|
||||||
|
idp = ProviderAdminService(get_settings())
|
||||||
|
|
||||||
system = systems_repo.get_by_key(payload.system_key)
|
system = systems_repo.get_by_key(payload.system_key)
|
||||||
if not system:
|
if not system:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
|
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)
|
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
|
||||||
try:
|
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:
|
def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends(get_db)) -> RoleItem:
|
||||||
systems_repo = SystemsRepository(db)
|
systems_repo = SystemsRepository(db)
|
||||||
roles_repo = RolesRepository(db)
|
roles_repo = RolesRepository(db)
|
||||||
|
idp = ProviderAdminService(get_settings())
|
||||||
|
|
||||||
role = roles_repo.get_by_key(role_key)
|
role = roles_repo.get_by_key(role_key)
|
||||||
if not role:
|
if not role:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found")
|
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
|
system_id = None
|
||||||
if payload.system_key:
|
if payload.system_key:
|
||||||
system = systems_repo.get_by_key(payload.system_key)
|
system = systems_repo.get_by_key(payload.system_key)
|
||||||
if not system:
|
if not system:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
|
||||||
system_id = system.id
|
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:
|
try:
|
||||||
role = roles_repo.update(
|
role = roles_repo.update(
|
||||||
@@ -502,11 +543,24 @@ def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends
|
|||||||
|
|
||||||
@router.delete("/roles/{role_key}")
|
@router.delete("/roles/{role_key}")
|
||||||
def delete_role(role_key: str, db: Session = Depends(get_db)) -> dict[str, str]:
|
def delete_role(role_key: str, db: Session = Depends(get_db)) -> dict[str, str]:
|
||||||
repo = RolesRepository(db)
|
roles_repo = RolesRepository(db)
|
||||||
role = repo.get_by_key(role_key)
|
systems_repo = SystemsRepository(db)
|
||||||
|
idp = ProviderAdminService(get_settings())
|
||||||
|
|
||||||
|
role = roles_repo.get_by_key(role_key)
|
||||||
if not role:
|
if not role:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found")
|
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}
|
return {"deleted": role_key}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ class ProviderGroupSyncResult:
|
|||||||
action: str
|
action: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProviderRoleSyncResult:
|
||||||
|
role_name: str
|
||||||
|
action: str
|
||||||
|
|
||||||
|
|
||||||
class ProviderAdminService:
|
class ProviderAdminService:
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(self, settings: Settings) -> None:
|
||||||
self.base_url = settings.keycloak_base_url.rstrip("/")
|
self.base_url = settings.keycloak_base_url.rstrip("/")
|
||||||
@@ -384,3 +390,119 @@ class ProviderAdminService:
|
|||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
||||||
payload = resp.json() if resp.content else []
|
payload = resp.json() if resp.content else []
|
||||||
return payload if isinstance(payload, list) 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")
|
||||||
|
|||||||
Reference in New Issue
Block a user