from __future__ import annotations from collections.abc import Mapping, Sequence from urllib.parse import quote from sqlalchemy import func, select from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal from govoplan_core.core.events import PlatformEvent from govoplan_core.core.modules import ModuleContext from govoplan_core.core.search import ( SearchAuthorizationRequest, SearchBackfillPage, SearchBackfillRequest, SearchDocument, SearchIndexChange, SearchResourceReference, SearchResourceType, ) from govoplan_idm.backend.db.models import ( IdmIdentityRelationship, IdmOrganizationFunctionAssignment, IdmTypedGroup, ) PROVIDER_ID = "idm.directory" RESOURCE_MODELS = { "organization_function_assignment": IdmOrganizationFunctionAssignment, "typed_group": IdmTypedGroup, "identity_relationship": IdmIdentityRelationship, } RESOURCE_SCOPES = { "organization_function_assignment": ( "idm:organization_assignment:read", "idm:organization_assignment:write", ), "typed_group": ("idm:relationship:read", "idm:relationship:write"), "identity_relationship": ( "idm:relationship:read", "idm:relationship:write", ), } class IdmSearchSource: def resource_types(self) -> Sequence[SearchResourceType]: return ( SearchResourceType( provider_id=PROVIDER_ID, module_id="idm", resource_type="organization_function_assignment", label="Function assignments", requires_authorization_recheck=True, ), SearchResourceType( provider_id=PROVIDER_ID, module_id="idm", resource_type="typed_group", label="Typed groups", requires_authorization_recheck=True, ), SearchResourceType( provider_id=PROVIDER_ID, module_id="idm", resource_type="identity_relationship", label="Identity relationships", requires_authorization_recheck=True, ), ) def backfill( self, session: object, *, request: SearchBackfillRequest, ) -> SearchBackfillPage: model = _model(request.provider_id, request.resource_type) db = _session(session) statement = select(model).where(model.tenant_id == request.tenant_id) if request.cursor: statement = statement.where(model.id > request.cursor) rows = list( db.scalars( statement.order_by(model.id).limit(request.limit + 1) ) ) has_more = len(rows) > request.limit selected = rows[: request.limit] high_watermark = db.scalar( select(func.max(model.updated_at)).where( model.tenant_id == request.tenant_id ) ) return SearchBackfillPage( documents=tuple( _document(row, resource_type=request.resource_type) for row in selected ), next_cursor=selected[-1].id if has_more and selected else None, complete=not has_more, high_watermark=( high_watermark.isoformat() if high_watermark is not None else None ), ) def authorize( self, session: object, principal: object, *, requests: Sequence[SearchAuthorizationRequest], ) -> Mapping[str, bool]: decisions = {item.reference.key: False for item in requests} if not isinstance(principal, ApiPrincipal): return decisions db = _session(session) by_type: dict[str, list[SearchAuthorizationRequest]] = {} for item in requests: reference = item.reference if ( reference.tenant_id != principal.tenant_id or reference.module_id != "idm" or reference.resource_type not in RESOURCE_MODELS or not any( principal.has(scope) for scope in RESOURCE_SCOPES[reference.resource_type] ) ): continue by_type.setdefault(reference.resource_type, []).append(item) for resource_type, items in by_type.items(): model = RESOURCE_MODELS[resource_type] ids = {item.reference.resource_id for item in items} existing = set( db.scalars( select(model.id).where( model.id.in_(ids), model.tenant_id == principal.tenant_id, ) ) ) for item in items: decisions[item.reference.key] = ( item.reference.resource_id in existing ) return decisions def index_changes_for_event( self, session: object, *, event: PlatformEvent, delivery_key: str, ) -> Sequence[SearchIndexChange]: if ( event.module_id != "idm" or event.tenant is None or event.resource is None or event.resource.type not in RESOURCE_MODELS or event.resource.id is None ): return () db = _session(session) resource_type = event.resource.type model = RESOURCE_MODELS[resource_type] row = db.get(model, event.resource.id) deleted = row is None or row.tenant_id != event.tenant.id cursor = event.event_id document = ( None if deleted else _document( row, resource_type=resource_type, change_cursor=cursor, ) ) reference = SearchResourceReference( tenant_id=event.tenant.id, module_id="idm", resource_type=resource_type, resource_id=event.resource.id, ) return ( SearchIndexChange( change_id=f"{delivery_key}:{PROVIDER_ID}:{resource_type}", provider_id=PROVIDER_ID, kind="delete" if deleted else "upsert", reference=reference, source_revision=( document.source_revision if document is not None else cursor ), cursor=cursor, document=document, occurred_at=event.occurred_at, ), ) def create_idm_search_source(_context: ModuleContext) -> IdmSearchSource: return IdmSearchSource() def _document( row: object, *, resource_type: str, change_cursor: str | None = None, ) -> SearchDocument: scopes = RESOURCE_SCOPES[resource_type] updated_at = getattr(row, "updated_at") or getattr(row, "created_at") title, summary, body, keywords, metadata, url = _resource_content( row, resource_type=resource_type, ) revision = getattr(row, "revision", None) return SearchDocument( tenant_id=str(getattr(row, "tenant_id")), module_id="idm", provider_id=PROVIDER_ID, resource_type=resource_type, resource_id=str(getattr(row, "id")), title=title[:500], url=url, summary=summary[:4000] if summary else None, body=body[:200_000] if body else None, keywords=tuple(item[:200] for item in keywords[:100]), visibility="restricted", acl_tokens=tuple(f"scope:{scope}" for scope in scopes), metadata=metadata, source_revision=f"{revision or 1}:{updated_at.isoformat()}", change_cursor=change_cursor, source_updated_at=updated_at, requires_authorization_recheck=True, ) def _resource_content( row: object, *, resource_type: str, ) -> tuple[str, str, str, tuple[str, ...], dict[str, object], str]: item_id = str(getattr(row, "id")) if resource_type == "typed_group": group = row return ( str(getattr(group, "name")), str(getattr(group, "description") or ""), " ".join( str(value) for value in ( getattr(group, "key"), getattr(group, "description"), getattr(group, "source_resource_id"), ) if value ), ( str(getattr(group, "group_type")), str(getattr(group, "status")), str(getattr(group, "source_provider")), ), { "key": getattr(group, "key"), "group_type": getattr(group, "group_type"), "status": getattr(group, "status"), "source_provider": getattr(group, "source_provider"), }, f"/idm?groupId={quote(item_id, safe='')}", ) if resource_type == "identity_relationship": relationship = row kind = str(getattr(relationship, "relationship_kind")) return ( f"Relationship: {kind}", str(getattr(relationship, "role") or ""), " ".join( str(value) for value in ( getattr(relationship, "subject_identity_id"), getattr(relationship, "target_group_id"), getattr(relationship, "related_identity_id"), getattr(relationship, "role"), ) if value ), (kind, str(getattr(relationship, "status"))), { "relationship_kind": kind, "status": getattr(relationship, "status"), "subject_identity_id": getattr( relationship, "subject_identity_id" ), "target_group_id": getattr(relationship, "target_group_id"), "related_identity_id": getattr( relationship, "related_identity_id" ), }, f"/idm?relationshipId={quote(item_id, safe='')}", ) assignment = row return ( "Organization function assignment", " ".join( ( str(getattr(assignment, "function_id")), str(getattr(assignment, "organization_unit_id")), ) ), " ".join( str(value) for value in ( getattr(assignment, "identity_id"), getattr(assignment, "account_id"), getattr(assignment, "function_id"), getattr(assignment, "organization_unit_id"), ) if value ), ( str(getattr(assignment, "source")), "active" if getattr(assignment, "is_active") else "inactive", ), { "identity_id": getattr(assignment, "identity_id"), "function_id": getattr(assignment, "function_id"), "organization_unit_id": getattr( assignment, "organization_unit_id" ), "is_active": getattr(assignment, "is_active"), "valid_from": ( getattr(assignment, "valid_from").isoformat() if getattr(assignment, "valid_from") else None ), "valid_until": ( getattr(assignment, "valid_until").isoformat() if getattr(assignment, "valid_until") else None ), }, f"/idm?assignmentId={quote(item_id, safe='')}", ) def _model(provider_id: str, resource_type: str): if provider_id != PROVIDER_ID or resource_type not in RESOURCE_MODELS: raise ValueError("Unsupported IDM search source.") return RESOURCE_MODELS[resource_type] def _session(value: object) -> Session: if not isinstance(value, Session): raise TypeError("IDM search requires a SQLAlchemy session.") return value __all__ = [ "IdmSearchSource", "PROVIDER_ID", "RESOURCE_MODELS", "create_idm_search_source", ]