diff --git a/AUTHENTICATION_AND_USER_MANAGEMENT.md b/AUTHENTICATION_AND_USER_MANAGEMENT.md index 8b04db3..1b4b3db 100644 --- a/AUTHENTICATION_AND_USER_MANAGEMENT.md +++ b/AUTHENTICATION_AND_USER_MANAGEMENT.md @@ -37,11 +37,17 @@ but still need a project membership for project business APIs. Every authenticated metadata-user resolution validates the Keycloak access token and reads `sub`, `preferred_username`, and `email` claims. The backend finds `users` by `keycloak_id = sub`, rejects inactive or missing users, -then refreshes `username`, `email`, and `last_login_at`. +then refreshes `username`, `email`, and `last_login_at` for active users. -This keeps local display data current without changing the identity binding. -There is no Keycloak webhook requirement; second-level user or permission sync is -out of scope unless explicitly requested later. +System administrators explicitly synchronize the Keycloak user directory from +the system-management page. New identities are stored with role `user` and +`is_active = false`; existing identities keep their TJWater role, active state, +superuser flag, and project memberships. This makes pending accounts visible +without requiring the user to log in and without granting access automatically. +The administrator's Keycloak account must have the `realm-management` +`query-users` and `view-users` client roles. The backend forwards that access +token only to the same token issuer's Keycloak Admin API; no management +credential is exposed to the browser. ## Admin APIs diff --git a/app/api/v1/endpoints/admin_metadata.py b/app/api/v1/endpoints/admin_metadata.py index a69579e..be778d1 100644 --- a/app/api/v1/endpoints/admin_metadata.py +++ b/app/api/v1/endpoints/admin_metadata.py @@ -12,11 +12,17 @@ from app.auth.metadata_dependencies import ( get_current_metadata_admin, get_metadata_repository, ) +from app.auth.keycloak_dependencies import ( + get_current_keycloak_payload, + oauth2_optional, +) from app.core.audit import AuditAction, log_audit_event from app.domain.schemas.admin_metadata import ( AdminProjectCreateRequest, AdminProjectResponse, AdminProjectUpdateRequest, + KeycloakUserSyncIssue, + KeycloakUserSyncResponse, MetadataUsersBatchSyncRequest, MetadataUserResponse, MetadataUserSyncRequest, @@ -36,6 +42,11 @@ from app.infra.db.metadb.repositories.metadata_repository import ( MetadataRepository, ProjectDbRouting, ) +from app.services.keycloak_user_sync import ( + KeycloakUserSyncConfigurationError, + KeycloakUserSyncRequestError, + fetch_keycloak_users, +) router = APIRouter() @@ -229,6 +240,94 @@ async def sync_metadata_users_batch( return results +@router.post( + "/admin/keycloak-user-syncs", + response_model=KeycloakUserSyncResponse, +) +async def sync_keycloak_user_directory( + current_user=Depends(get_current_metadata_admin), + keycloak_payload: dict = Depends(get_current_keycloak_payload), + keycloak_access_token: str | None = Depends(oauth2_optional), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> KeycloakUserSyncResponse: + try: + keycloak_users = await fetch_keycloak_users( + issuer=str(keycloak_payload.get("iss") or ""), + bearer_token=keycloak_access_token or "", + ) + except KeycloakUserSyncConfigurationError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except KeycloakUserSyncRequestError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + + created = 0 + updated = 0 + issues: list[KeycloakUserSyncIssue] = [] + for keycloak_user in keycloak_users: + if keycloak_user.service_account_client_id: + issues.append( + KeycloakUserSyncIssue( + username=keycloak_user.username, + reason="service_account", + ) + ) + continue + if not keycloak_user.email: + issues.append( + KeycloakUserSyncIssue( + username=keycloak_user.username, + reason="missing_email", + ) + ) + continue + try: + _, was_created = await metadata_repo.sync_user_snapshot_from_keycloak( + keycloak_id=keycloak_user.keycloak_id, + username=keycloak_user.username, + email=keycloak_user.email, + ) + except IntegrityError: + await metadata_repo.session.rollback() + issues.append( + KeycloakUserSyncIssue( + username=keycloak_user.username, + reason="identity_conflict", + ) + ) + except SQLAlchemyError as exc: + await metadata_repo.session.rollback() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Metadata database error during Keycloak user synchronization", + ) from exc + else: + created += int(was_created) + updated += int(not was_created) + + result = KeycloakUserSyncResponse( + scanned=len(keycloak_users), + created=created, + updated=updated, + skipped=len(issues), + issues=issues, + ) + await log_audit_event( + action=AuditAction.UPDATE, + user_id=current_user.id, + resource_type="keycloak_user_directory", + request_data=result.model_dump(mode="json", exclude={"issues"}), + response_status=status.HTTP_200_OK, + session=metadata_repo.session, + ) + return result + + @router.get("/admin/users", response_model=List[MetadataUserResponse]) async def list_metadata_users( skip: int = Query(0, ge=0), diff --git a/app/auth/metadata_dependencies.py b/app/auth/metadata_dependencies.py index 47742f9..6b41837 100644 --- a/app/auth/metadata_dependencies.py +++ b/app/auth/metadata_dependencies.py @@ -52,6 +52,7 @@ async def get_current_metadata_user( ): keycloak_sub = _keycloak_sub_from_payload(keycloak_payload) username = get_keycloak_preferred_username(keycloak_payload) + email = _email_from_payload(keycloak_payload) try: user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub) except SQLAlchemyError as exc: @@ -65,13 +66,14 @@ async def get_current_metadata_user( ) from exc if not user or not user.is_active: raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" + status_code=status.HTTP_403_FORBIDDEN, + detail="User authorization pending", ) try: user = await metadata_repo.refresh_user_keycloak_snapshot( user, username=username, - email=_email_from_payload(keycloak_payload), + email=email, ) except SQLAlchemyError as exc: logger.error( diff --git a/app/domain/schemas/admin_metadata.py b/app/domain/schemas/admin_metadata.py index 584cc4b..461c94e 100644 --- a/app/domain/schemas/admin_metadata.py +++ b/app/domain/schemas/admin_metadata.py @@ -50,6 +50,19 @@ class MetadataUserSyncResult(BaseModel): error: str | None = None +class KeycloakUserSyncIssue(BaseModel): + username: str + reason: str + + +class KeycloakUserSyncResponse(BaseModel): + scanned: int + created: int + updated: int + skipped: int + issues: list[KeycloakUserSyncIssue] = Field(default_factory=list) + + class ProjectMemberCreateRequest(BaseModel): user_id: UUID project_role: ProjectRole = "viewer" diff --git a/app/infra/db/metadb/repositories/metadata_repository.py b/app/infra/db/metadb/repositories/metadata_repository.py index b75240f..1bf4c3f 100644 --- a/app/infra/db/metadb/repositories/metadata_repository.py +++ b/app/infra/db/metadb/repositories/metadata_repository.py @@ -5,6 +5,7 @@ from uuid import UUID, uuid4 from cryptography.fernet import InvalidToken from sqlalchemy import delete, func, select +from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from app.core.encryption import ( @@ -158,6 +159,44 @@ class MetadataRepository: await self.session.refresh(user) return user + async def sync_user_snapshot_from_keycloak( + self, + *, + keycloak_id: UUID, + username: str, + email: str, + ) -> tuple[models.User, bool]: + user = await self.get_user_by_keycloak_id(keycloak_id) + if user is not None: + user.username = username + user.email = email + user.updated_at = _utcnow() + await self.session.commit() + await self.session.refresh(user) + return user, False + + statement = ( + insert(models.User) + .values( + id=uuid4(), + keycloak_id=keycloak_id, + username=username, + email=email, + role="user", + is_active=False, + is_superuser=False, + ) + .on_conflict_do_nothing(index_elements=[models.User.keycloak_id]) + .returning(models.User.id) + ) + result = await self.session.execute(statement) + inserted_id = result.scalar_one_or_none() + await self.session.commit() + user = await self.get_user_by_keycloak_id(keycloak_id) + if user is None: + raise RuntimeError("Keycloak user synchronization did not persist a user") + return user, inserted_id is not None + async def refresh_user_keycloak_snapshot( self, user: models.User, diff --git a/app/services/keycloak_user_sync.py b/app/services/keycloak_user_sync.py new file mode 100644 index 0000000..caebd56 --- /dev/null +++ b/app/services/keycloak_user_sync.py @@ -0,0 +1,117 @@ +from dataclasses import dataclass +from urllib.parse import quote +from uuid import UUID + +import httpx + + +class KeycloakUserSyncConfigurationError(ValueError): + pass + + +class KeycloakUserSyncRequestError(RuntimeError): + pass + + +@dataclass(frozen=True) +class KeycloakUserSnapshot: + keycloak_id: UUID + username: str + email: str | None + service_account_client_id: str | None = None + + +def _keycloak_admin_users_url(issuer: str) -> str: + issuer = issuer.rstrip("/") + marker = "/realms/" + if not issuer or marker not in issuer: + raise KeycloakUserSyncConfigurationError( + "Keycloak token issuer is not a complete realm URL" + ) + server_url, realm = issuer.rsplit(marker, 1) + if not server_url or not realm: + raise KeycloakUserSyncConfigurationError( + "Keycloak token issuer is not a complete realm URL" + ) + return f"{server_url}/admin/realms/{quote(realm, safe='')}/users" + + +async def fetch_keycloak_users( + *, + issuer: str, + bearer_token: str, + client: httpx.AsyncClient | None = None, +) -> list[KeycloakUserSnapshot]: + admin_users_url = _keycloak_admin_users_url(issuer) + if not bearer_token: + raise KeycloakUserSyncConfigurationError( + "Keycloak administrator access token is missing" + ) + owns_client = client is None + if client is None: + client = httpx.AsyncClient(timeout=15.0) + + try: + try: + snapshots: list[KeycloakUserSnapshot] = [] + first = 0 + page_size = 100 + while True: + response = await client.get( + admin_users_url, + params={"first": first, "max": page_size}, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {bearer_token}", + }, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + raise KeycloakUserSyncRequestError( + "Keycloak users response is not a list" + ) + for item in payload: + if not isinstance(item, dict): + continue + try: + keycloak_id = UUID(str(item.get("id", ""))) + except ValueError: + continue + username = str(item.get("username") or "").strip() + if not username: + continue + email_value = item.get("email") + email = str(email_value).strip() if email_value else None + service_account_value = item.get("serviceAccountClientId") + snapshots.append( + KeycloakUserSnapshot( + keycloak_id=keycloak_id, + username=username, + email=email, + service_account_client_id=( + str(service_account_value) + if service_account_value + else None + ), + ) + ) + if len(payload) < page_size: + break + first += page_size + return snapshots + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 403: + raise KeycloakUserSyncRequestError( + "Keycloak administrator requires query-users and view-users roles" + ) from exc + raise KeycloakUserSyncRequestError( + "Keycloak user directory request failed" + ) from exc + except httpx.HTTPError as exc: + raise KeycloakUserSyncRequestError( + "Keycloak user directory request failed" + ) from exc + finally: + if owns_client: + await client.aclose() diff --git a/contracts/manifest.json b/contracts/manifest.json index 66a3720..757074f 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "5b8ec0b469f1194e72214003a9cf427489f48de19ae31ee6492821cf64916b6d" + "sha256": "19961572fad04f8545a4f607d4bf7276de64a84b266daecbb21d74e0975f97ef" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index b986ce0..c4efa32 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -89,6 +89,7 @@ "map_extent": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -151,6 +152,7 @@ "map_extent": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -232,6 +234,7 @@ "map_extent": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -378,6 +381,7 @@ "request_data": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -570,6 +574,7 @@ "link_result_list": { "description": "管道模拟结果列表", "items": { + "additionalProperties": true, "type": "object" }, "title": "Link Result List", @@ -578,6 +583,7 @@ "node_result_list": { "description": "节点模拟结果列表", "items": { + "additionalProperties": true, "type": "object" }, "title": "Node Result List", @@ -602,6 +608,7 @@ "iforest_params": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -628,6 +635,7 @@ }, { "items": { + "additionalProperties": true, "type": "object" }, "type": "array" @@ -767,6 +775,7 @@ }, { "items": { + "additionalProperties": true, "type": "object" }, "type": "array" @@ -793,6 +802,7 @@ }, { "items": { + "additionalProperties": true, "type": "object" }, "type": "array" @@ -845,6 +855,7 @@ }, { "items": { + "additionalProperties": true, "type": "object" }, "type": "array" @@ -866,6 +877,7 @@ }, { "items": { + "additionalProperties": true, "type": "object" }, "type": "array" @@ -1201,6 +1213,59 @@ "type": "string" }, "JsonValue": {}, + "KeycloakUserSyncIssue": { + "properties": { + "reason": { + "title": "Reason", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "reason" + ], + "title": "KeycloakUserSyncIssue", + "type": "object" + }, + "KeycloakUserSyncResponse": { + "properties": { + "created": { + "title": "Created", + "type": "integer" + }, + "issues": { + "items": { + "$ref": "#/components/schemas/KeycloakUserSyncIssue" + }, + "title": "Issues", + "type": "array" + }, + "scanned": { + "title": "Scanned", + "type": "integer" + }, + "skipped": { + "title": "Skipped", + "type": "integer" + }, + "updated": { + "title": "Updated", + "type": "integer" + } + }, + "required": [ + "scanned", + "created", + "updated", + "skipped" + ], + "title": "KeycloakUserSyncResponse", + "type": "object" + }, "LeakageIdentifyRequestRest": { "properties": { "dma_count": { @@ -1251,6 +1316,7 @@ }, { "items": { + "additionalProperties": true, "type": "object" }, "type": "array" @@ -1845,6 +1911,7 @@ "properties": { "items": { "items": { + "additionalProperties": true, "type": "object" }, "title": "Items", @@ -1919,6 +1986,7 @@ "title": "Duration" }, "pump_control": { + "additionalProperties": true, "description": "泵控制策略", "title": "Pump Control", "type": "object" @@ -1937,6 +2005,7 @@ "tank_init_level": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -1968,6 +2037,7 @@ }, "errors": { "items": { + "additionalProperties": true, "type": "object" }, "title": "Errors", @@ -2254,6 +2324,7 @@ "map_extent": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -3201,6 +3272,92 @@ ] } }, + "/api/v1/admin/keycloak-user-syncs": { + "post": { + "operationId": "post_admin_keycloak_user_syncs", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeycloakUserSyncResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Sync Keycloak User Directory", + "tags": [ + "Metadata Admin" + ] + } + }, "/api/v1/admin/project-provisions": { "post": { "operationId": "post_admin_project_provisions", @@ -4544,6 +4701,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Patch Admin Projects Project Id Model Imports", "type": "object" } @@ -4651,6 +4809,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Admin Projects Project Id Model Imports", "type": "object" } @@ -6459,6 +6618,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Audit Logs Count", "type": "object" } @@ -6730,6 +6890,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Backdrops Properties", "type": "object" } @@ -6826,6 +6987,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -7111,6 +7273,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Burst Detections", "type": "object" } @@ -7221,6 +7384,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Burst Locations", "type": "object" } @@ -7491,6 +7655,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Controls Properties", "type": "object" } @@ -7587,6 +7752,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -7926,6 +8092,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -8157,6 +8324,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Curves Properties", "type": "object" } @@ -8264,6 +8432,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -8385,6 +8554,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Demands Properties", "type": "object" } @@ -8492,6 +8662,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -8594,7 +8765,7 @@ "required": true, "schema": { "description": "总需水量(m³/h)", - "exclusiveMinimum": 0.0, + "exclusiveMinimum": 0, "title": "Demand", "type": "number" } @@ -8715,6 +8886,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -8828,6 +9000,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -8953,6 +9126,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Element Properties", "type": "object" } @@ -9074,6 +9248,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Element Properties With Types", "type": "object" } @@ -9404,6 +9579,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Emitters Properties", "type": "object" } @@ -9511,6 +9687,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -9620,6 +9797,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -9990,6 +10168,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Geocoding Requests", "type": "object" } @@ -11497,6 +11676,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Junctions Properties", "type": "object" } @@ -11604,6 +11784,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -12169,6 +12350,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -12269,6 +12451,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -12401,6 +12584,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Labels Properties", "type": "object" } @@ -12497,6 +12681,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -12618,6 +12803,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Leakage Identifications", "type": "object" } @@ -12728,6 +12914,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Link Properties", "type": "object" } @@ -13505,6 +13692,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -13605,6 +13793,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -13712,6 +13901,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -13833,6 +14023,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Mixing Configurations Detail", "type": "object" } @@ -13976,6 +14167,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Network In Extents", "type": "object" } @@ -14184,6 +14376,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Network Options", "type": "object" } @@ -14280,6 +14473,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -14390,6 +14584,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Network Options Energy", "type": "object" } @@ -14500,6 +14695,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Network Options Pump Energy", "type": "object" } @@ -14607,6 +14803,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -14717,6 +14914,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Network Options Time", "type": "object" } @@ -14817,6 +15015,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Backdrop", @@ -14919,6 +15118,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Control", @@ -15021,6 +15221,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Curve", @@ -15123,6 +15324,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Demand", @@ -15225,6 +15427,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Emitter", @@ -15327,6 +15530,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Energy", @@ -15429,6 +15633,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Junction", @@ -15531,6 +15736,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Label", @@ -15633,6 +15839,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Mixing", @@ -15735,6 +15942,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Option", @@ -15837,6 +16045,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Pattern", @@ -15939,6 +16148,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Pipe", @@ -16041,6 +16251,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Pipe Reaction", @@ -16143,6 +16354,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Pump", @@ -16245,6 +16457,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Pump Energy", @@ -16347,6 +16560,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Quality", @@ -16449,6 +16663,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Reaction", @@ -16550,6 +16765,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Region", @@ -16652,6 +16868,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Reservoir", @@ -16753,6 +16970,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Scada Device", @@ -16855,6 +17073,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Source", @@ -16957,6 +17176,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Tag", @@ -17059,6 +17279,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Tank", @@ -17161,6 +17382,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Tank Reaction", @@ -17263,6 +17485,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Time", @@ -17365,6 +17588,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Valve", @@ -17467,6 +17691,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Vertex", @@ -17831,6 +18056,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Node Properties", "type": "object" } @@ -18600,6 +18826,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -18831,6 +19058,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Patterns Properties", "type": "object" } @@ -18938,6 +19166,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -19047,6 +19276,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -19168,6 +19398,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Pipe Reactions Detail", "type": "object" } @@ -21083,6 +21314,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Pipes Properties", "type": "object" } @@ -21190,6 +21422,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -21880,6 +22113,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Project Return Dict Runs", "type": "object" } @@ -23545,6 +23779,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Pumps Properties", "type": "object" } @@ -23652,6 +23887,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -23773,6 +24009,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Quality Configurations Properties", "type": "object" } @@ -23869,6 +24106,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -23978,6 +24216,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -24088,6 +24327,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Reactions Detail", "type": "object" } @@ -24185,6 +24425,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -24402,6 +24643,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -24508,6 +24750,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -24628,6 +24871,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Regions Detail", "type": "object" } @@ -26059,6 +26303,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Reservoirs Properties", "type": "object" } @@ -26166,6 +26411,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -26752,6 +26998,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Rule Properties", "type": "object" } @@ -26848,6 +27095,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -26959,6 +27207,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Rule Schemas", @@ -27297,6 +27546,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Scada Properties", "type": "object" } @@ -28280,6 +28530,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -28387,6 +28638,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -28508,6 +28760,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Sources Detail", "type": "object" } @@ -28618,6 +28871,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Status", "type": "object" } @@ -28727,6 +28981,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -28838,6 +29093,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Status Schemas", @@ -29057,6 +29313,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -29189,6 +29446,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Tags Detail", "type": "object" } @@ -29287,6 +29545,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -29408,6 +29667,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Tank Reactions Detail", "type": "object" } @@ -31936,6 +32196,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Tanks Properties", "type": "object" } @@ -32043,6 +32304,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -32843,6 +33105,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -35497,6 +35760,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Title Schemas", @@ -35598,6 +35862,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Titles", "type": "object" } @@ -35694,6 +35959,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -37440,6 +37706,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Valves Properties", "type": "object" } @@ -37547,6 +37814,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -38126,6 +38394,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -38226,6 +38495,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -38468,6 +38738,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Visual Elements Properties", "type": "object" } @@ -38564,6 +38835,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -38684,6 +38956,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Web Searches", "type": "object" } diff --git a/tests/api/test_admin_metadata_endpoints.py b/tests/api/test_admin_metadata_endpoints.py index 3db355c..335d3c2 100644 --- a/tests/api/test_admin_metadata_endpoints.py +++ b/tests/api/test_admin_metadata_endpoints.py @@ -20,6 +20,7 @@ from app.domain.schemas.admin_metadata import ( ProjectMemberUpdateRequest, ) from app.infra.db.metadb.repositories.metadata_repository import ProjectDbRouting +from app.services.keycloak_user_sync import KeycloakUserSnapshot @pytest.fixture @@ -176,6 +177,66 @@ async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch): assert admin_metadata.log_audit_event.await_count == 2 +@pytest.mark.anyio +async def test_sync_keycloak_user_directory_preserves_authorization(monkeypatch): + new_id = uuid4() + existing_id = uuid4() + repo = SimpleNamespace( + session=SimpleNamespace(rollback=AsyncMock()), + sync_user_snapshot_from_keycloak=AsyncMock( + side_effect=[ + (_user(keycloak_id=new_id, is_active=False), True), + (_user(keycloak_id=existing_id, role="admin"), False), + ] + ), + ) + monkeypatch.setattr( + admin_metadata, + "fetch_keycloak_users", + AsyncMock( + return_value=[ + KeycloakUserSnapshot(new_id, "new-user", "new@example.com"), + KeycloakUserSnapshot(existing_id, "admin-user", "admin@example.com"), + KeycloakUserSnapshot(uuid4(), "no-email", None), + KeycloakUserSnapshot( + uuid4(), + "service-account-client", + "service@example.com", + service_account_client_id="client", + ), + ] + ), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + response = await admin_metadata.sync_keycloak_user_directory( + current_user=_user(role="admin", is_superuser=True), + keycloak_payload={"iss": "https://keycloak.example.com/realms/tjwater"}, + keycloak_access_token="admin-token", + metadata_repo=repo, + ) + + assert response.scanned == 4 + assert response.created == 1 + assert response.updated == 1 + assert response.skipped == 2 + assert [issue.reason for issue in response.issues] == [ + "missing_email", + "service_account", + ] + assert repo.sync_user_snapshot_from_keycloak.await_count == 2 + admin_metadata.fetch_keycloak_users.assert_awaited_once_with( + issuer="https://keycloak.example.com/realms/tjwater", + bearer_token="admin-token", + ) + assert repo.sync_user_snapshot_from_keycloak.await_args_list[1].kwargs == { + "keycloak_id": existing_id, + "username": "admin-user", + "email": "admin@example.com", + } + admin_metadata.log_audit_event.assert_awaited_once() + + @pytest.mark.anyio async def test_update_metadata_user_updates_role_and_active_status(monkeypatch): user_id = uuid4() diff --git a/tests/unit/test_keycloak_user_sync.py b/tests/unit/test_keycloak_user_sync.py new file mode 100644 index 0000000..f819ddd --- /dev/null +++ b/tests/unit/test_keycloak_user_sync.py @@ -0,0 +1,54 @@ +from uuid import uuid4 + +import httpx +import pytest + +from app.services import keycloak_user_sync + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.mark.anyio +async def test_fetch_keycloak_users_uses_admin_token_and_paginates(): + user_ids = [uuid4() for _ in range(101)] + requested_first: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["authorization"] == "Bearer admin-token" + first = int(request.url.params["first"]) + requested_first.append(first) + page_ids = user_ids[first : first + 100] + return httpx.Response( + 200, + json=[ + { + "id": str(user_id), + "username": f"user-{first + index}", + "email": f"user-{first + index}@example.com", + } + for index, user_id in enumerate(page_ids) + ], + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + users = await keycloak_user_sync.fetch_keycloak_users( + issuer="https://keycloak.example.com/realms/tjwater", + bearer_token="admin-token", + client=client, + ) + + assert len(users) == 101 + assert requested_first == [0, 100] + assert users[-1].username == "user-100" + + +@pytest.mark.anyio +async def test_fetch_keycloak_users_requires_realm_issuer(): + with pytest.raises(keycloak_user_sync.KeycloakUserSyncConfigurationError): + await keycloak_user_sync.fetch_keycloak_users( + issuer="", + bearer_token="admin-token", + ) diff --git a/tests/unit/test_metadata_repository_users.py b/tests/unit/test_metadata_repository_users.py new file mode 100644 index 0000000..9a2ce32 --- /dev/null +++ b/tests/unit/test_metadata_repository_users.py @@ -0,0 +1,44 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.mark.anyio +async def test_keycloak_snapshot_sync_preserves_existing_authorization(): + keycloak_id = uuid4() + user = SimpleNamespace( + keycloak_id=keycloak_id, + username="old-name", + email="old@example.com", + role="admin", + is_active=True, + is_superuser=True, + updated_at=None, + ) + session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock()) + repository = MetadataRepository(session) + repository.get_user_by_keycloak_id = AsyncMock(return_value=user) + + result, created = await repository.sync_user_snapshot_from_keycloak( + keycloak_id=keycloak_id, + username="new-name", + email="new@example.com", + ) + + assert created is False + assert result.username == "new-name" + assert result.email == "new@example.com" + assert result.role == "admin" + assert result.is_active is True + assert result.is_superuser is True + session.commit.assert_awaited_once() + session.refresh.assert_awaited_once_with(user)