from __future__ import annotations from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, require_any_scope from govoplan_core.core.events import ( EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event, ) from govoplan_core.core.identity import ( CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory, ) from govoplan_core.core.idm import ( CAPABILITY_IDM_RELATIONSHIPS, IdentityRelationshipDecisionRef, IdentityRelationshipRef, IdmRelationshipDirectory, TypedGroupRef, ) from govoplan_core.core.principal_cache import invalidate_auth_principals from govoplan_core.core.runtime import get_registry from govoplan_core.db.session import get_session from govoplan_core.security.time import ensure_aware_utc, utc_now from govoplan_idm.backend.db.models import ( IdmIdentityRelationship, IdmTypedGroup, ) from .schemas import ( IdentityRelationshipCreateRequest, IdentityRelationshipDecisionItem, IdentityRelationshipItem, IdentityRelationshipList, IdentityRelationshipRevokeRequest, IdentityRelationshipUpdateRequest, TypedGroupCreateRequest, TypedGroupItem, TypedGroupList, TypedGroupMembershipResolutionItem, TypedGroupUpdateRequest, ) router = APIRouter() RELATIONSHIP_READ_SCOPES = ( "idm:relationship:read", "idm:relationship:write", ) RELATIONSHIP_WRITE_SCOPES = ("idm:relationship:write",) def _not_found(label: str) -> HTTPException: return HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"{label} not found", ) def _invalid(message: str) -> HTTPException: return HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=message, ) def _conflict(message: str) -> HTTPException: return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message) def _typed_group_item(item: IdmTypedGroup) -> TypedGroupItem: return TypedGroupItem.model_validate( {column.name: getattr(item, column.name) for column in item.__table__.columns} ) def _relationship_item(item: IdmIdentityRelationship) -> IdentityRelationshipItem: return IdentityRelationshipItem.model_validate( {column.name: getattr(item, column.name) for column in item.__table__.columns} ) def _typed_group_ref_item(item: TypedGroupRef) -> TypedGroupItem: return TypedGroupItem( id=item.id, tenant_id=item.tenant_id, key=item.key, name=item.name, group_type=item.group_type, description=item.description, status=item.status, source_provider=item.source_provider, source_resource_type=item.source_resource_type, source_resource_id=item.source_resource_id, source_revision=item.source_revision, properties=dict(item.properties), provenance=dict(item.provenance), revision=item.revision, ) def _relationship_ref_item( item: IdentityRelationshipRef, ) -> IdentityRelationshipItem: return IdentityRelationshipItem( id=item.id, tenant_id=item.tenant_id, relationship_kind=item.relationship_kind, subject_identity_id=item.subject_identity_id, target_group_id=item.target_group_id, related_identity_id=item.related_identity_id, role=item.role, valid_from=item.valid_from, valid_until=item.valid_until, status=item.status, revoked_at=item.revoked_at, revoked_by=item.revoked_by, revocation_reason=item.revocation_reason, source_provider=item.source_provider, source_resource_type=item.source_resource_type, source_resource_id=item.source_resource_id, source_revision=item.source_revision, properties=dict(item.properties), provenance=dict(item.provenance), revision=item.revision, ) def _decision_item( item: IdentityRelationshipDecisionRef, ) -> IdentityRelationshipDecisionItem: return IdentityRelationshipDecisionItem( relationship=_relationship_ref_item(item.relationship), included=item.included, code=item.code, explanation=item.explanation, identity_status=item.identity_status, ) def _tenant_row(session: Session, model, item_id: str, tenant_id: str, label: str): item = session.get(model, item_id) if item is None or item.tenant_id != tenant_id: raise _not_found(label) return item def _identity_directory() -> IdentityDirectory: registry = get_registry() if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Identity directory is unavailable", ) capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY) if not isinstance(capability, IdentityDirectory): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}", ) return capability def _relationship_directory() -> IdmRelationshipDirectory: registry = get_registry() if registry is None or not registry.has_capability(CAPABILITY_IDM_RELATIONSHIPS): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="IDM relationship directory is unavailable", ) capability = registry.require_capability(CAPABILITY_IDM_RELATIONSHIPS) if not isinstance(capability, IdmRelationshipDirectory): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_IDM_RELATIONSHIPS}", ) return capability def _ensure_identity(identity_id: str, *, label: str = "Identity") -> None: if _identity_directory().get_identity(identity_id) is None: raise _not_found(label) def _ensure_target_shape( target_group_id: str | None, related_identity_id: str | None, ) -> None: if (target_group_id is None) == (related_identity_id is None): raise _invalid( "A relationship must target exactly one typed group or related identity." ) def _ensure_window(valid_from: datetime | None, valid_until: datetime | None) -> None: start = ensure_aware_utc(valid_from) end = ensure_aware_utc(valid_until) if start is not None and end is not None and end <= start: raise _invalid("Relationship end must be after its start.") def _emit_change( session: Session, principal: ApiPrincipal, *, event_type: str, resource_type: str, resource_id: str, payload: dict[str, object], subject_identity_id: str | None = None, ) -> None: emit_platform_event( session, PlatformEvent( type=event_type, module_id="idm", payload=payload, actor=EventActorRef(type="account", id=principal.account_id), tenant=EventTenantRef(id=principal.tenant_id), subject=( EventObjectRef(type="identity", id=subject_identity_id) if subject_identity_id is not None else None ), resource=EventObjectRef(type=resource_type, id=resource_id), classification="internal", ), ) def _commit(session: Session, principal: ApiPrincipal, item, *, resource_type: str): invalidate_auth_principals( session, tenant_id=principal.tenant_id, source_module="idm", resource_type=resource_type, resource_id=item.id, ) try: session.commit() except IntegrityError as exc: session.rollback() raise _conflict("The IDM relationship conflicts with existing data.") from exc session.refresh(item) return item @router.get("/typed-groups", response_model=TypedGroupList) def list_typed_groups( query: str | None = Query(default=None, max_length=255), group_type: str | None = Query(default=None, max_length=80), include_inactive: bool = False, limit: int = Query(default=100, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_READ_SCOPES)), ) -> TypedGroupList: statement = session.query(IdmTypedGroup).filter( IdmTypedGroup.tenant_id == principal.tenant_id ) if not include_inactive: statement = statement.filter(IdmTypedGroup.status == "active") if group_type: statement = statement.filter(IdmTypedGroup.group_type == group_type) if query: statement = statement.filter(IdmTypedGroup.name.ilike(f"%{query.strip()}%")) total = statement.count() rows = ( statement.order_by(IdmTypedGroup.name.asc(), IdmTypedGroup.id.asc()) .limit(limit) .all() ) return TypedGroupList(groups=[_typed_group_item(item) for item in rows], total=total) @router.post( "/typed-groups", response_model=TypedGroupItem, status_code=status.HTTP_201_CREATED, ) def create_typed_group( payload: TypedGroupCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)), ) -> TypedGroupItem: item = IdmTypedGroup( tenant_id=principal.tenant_id, **payload.model_dump(), status="active", revision=1, ) session.add(item) try: session.flush() except IntegrityError as exc: session.rollback() raise _conflict("A typed group with this type and key already exists.") from exc _emit_change( session, principal, event_type="idm.typed_group.created.v1", resource_type="typed_group", resource_id=item.id, payload={"group_type": item.group_type, "key": item.key, "revision": 1}, ) return _typed_group_item( _commit(session, principal, item, resource_type="typed_group") ) @router.patch("/typed-groups/{group_id}", response_model=TypedGroupItem) def update_typed_group( group_id: str, payload: TypedGroupUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)), ) -> TypedGroupItem: item = _tenant_row( session, IdmTypedGroup, group_id, principal.tenant_id, "Typed group" ) if item.revision != payload.base_revision: raise _conflict("The typed group changed since it was loaded.") values = payload.model_dump(exclude_unset=True, exclude={"base_revision"}) for key, value in values.items(): setattr(item, key, value) item.revision += 1 session.flush() _emit_change( session, principal, event_type="idm.typed_group.changed.v1", resource_type="typed_group", resource_id=item.id, payload={"group_type": item.group_type, "key": item.key, "revision": item.revision}, ) return _typed_group_item( _commit(session, principal, item, resource_type="typed_group") ) @router.get("/relationships", response_model=IdentityRelationshipList) def list_identity_relationships( identity_id: str | None = Query(default=None, max_length=36), group_id: str | None = Query(default=None, max_length=36), relationship_kind: str | None = Query(default=None, max_length=80), include_revoked: bool = False, limit: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_READ_SCOPES)), ) -> IdentityRelationshipList: statement = session.query(IdmIdentityRelationship).filter( IdmIdentityRelationship.tenant_id == principal.tenant_id ) if identity_id: statement = statement.filter( IdmIdentityRelationship.subject_identity_id == identity_id ) if group_id: statement = statement.filter(IdmIdentityRelationship.target_group_id == group_id) if relationship_kind: statement = statement.filter( IdmIdentityRelationship.relationship_kind == relationship_kind ) if not include_revoked: statement = statement.filter(IdmIdentityRelationship.status == "active") total = statement.count() rows = ( statement.order_by( IdmIdentityRelationship.created_at.asc(), IdmIdentityRelationship.id.asc(), ) .limit(limit) .all() ) return IdentityRelationshipList( relationships=[_relationship_item(item) for item in rows], total=total ) @router.post( "/relationships", response_model=IdentityRelationshipItem, status_code=status.HTTP_201_CREATED, ) def create_identity_relationship( payload: IdentityRelationshipCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)), ) -> IdentityRelationshipItem: _ensure_target_shape(payload.target_group_id, payload.related_identity_id) _ensure_window(payload.valid_from, payload.valid_until) _ensure_identity(payload.subject_identity_id) if payload.related_identity_id: _ensure_identity(payload.related_identity_id, label="Related identity") if payload.target_group_id: _tenant_row( session, IdmTypedGroup, payload.target_group_id, principal.tenant_id, "Typed group", ) item = IdmIdentityRelationship( tenant_id=principal.tenant_id, **payload.model_dump(), status="active", revision=1, ) session.add(item) session.flush() _emit_change( session, principal, event_type="idm.relationship.created.v1", resource_type="identity_relationship", resource_id=item.id, subject_identity_id=item.subject_identity_id, payload={ "relationship_kind": item.relationship_kind, "target_group_id": item.target_group_id, "related_identity_id": item.related_identity_id, "revision": 1, }, ) return _relationship_item( _commit(session, principal, item, resource_type="identity_relationship") ) @router.patch("/relationships/{relationship_id}", response_model=IdentityRelationshipItem) def update_identity_relationship( relationship_id: str, payload: IdentityRelationshipUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)), ) -> IdentityRelationshipItem: item = _tenant_row( session, IdmIdentityRelationship, relationship_id, principal.tenant_id, "Identity relationship", ) if item.status == "revoked": raise _conflict("A revoked relationship cannot be changed.") if item.revision != payload.base_revision: raise _conflict("The identity relationship changed since it was loaded.") values = payload.model_dump(exclude_unset=True, exclude={"base_revision"}) if values.get("target_group_id") is not None: values["related_identity_id"] = None if values.get("related_identity_id") is not None: values["target_group_id"] = None target_group_id = values.get("target_group_id", item.target_group_id) related_identity_id = values.get("related_identity_id", item.related_identity_id) _ensure_target_shape(target_group_id, related_identity_id) valid_from = values.get("valid_from", item.valid_from) valid_until = values.get("valid_until", item.valid_until) _ensure_window(valid_from, valid_until) if target_group_id: _tenant_row( session, IdmTypedGroup, target_group_id, principal.tenant_id, "Typed group", ) if related_identity_id: _ensure_identity(related_identity_id, label="Related identity") for key, value in values.items(): setattr(item, key, value) item.revision += 1 if item.valid_until is None or ensure_aware_utc(item.valid_until) > utc_now(): item.expired_event_at = None session.flush() _emit_change( session, principal, event_type="idm.relationship.changed.v1", resource_type="identity_relationship", resource_id=item.id, subject_identity_id=item.subject_identity_id, payload={"relationship_kind": item.relationship_kind, "revision": item.revision}, ) return _relationship_item( _commit(session, principal, item, resource_type="identity_relationship") ) @router.post( "/relationships/{relationship_id}/revoke", response_model=IdentityRelationshipItem, ) def revoke_identity_relationship( relationship_id: str, payload: IdentityRelationshipRevokeRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)), ) -> IdentityRelationshipItem: item = _tenant_row( session, IdmIdentityRelationship, relationship_id, principal.tenant_id, "Identity relationship", ) if item.revision != payload.base_revision: raise _conflict("The identity relationship changed since it was loaded.") if item.status == "revoked": return _relationship_item(item) item.status = "revoked" item.revoked_at = utc_now() item.revoked_by = principal.account_id item.revocation_reason = payload.reason item.revision += 1 session.flush() _emit_change( session, principal, event_type="idm.relationship.revoked.v1", resource_type="identity_relationship", resource_id=item.id, subject_identity_id=item.subject_identity_id, payload={ "relationship_kind": item.relationship_kind, "reason": payload.reason, "revision": item.revision, }, ) return _relationship_item( _commit(session, principal, item, resource_type="identity_relationship") ) @router.get( "/typed-groups/{group_id}/memberships", response_model=TypedGroupMembershipResolutionItem, ) def resolve_typed_group_memberships( group_id: str, effective_at: datetime | None = None, relationship_kind: list[str] = Query(default=["member"]), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_READ_SCOPES)), ) -> TypedGroupMembershipResolutionItem: _tenant_row( session, IdmTypedGroup, group_id, principal.tenant_id, "Typed group" ) resolved = _relationship_directory().resolve_typed_group_memberships( (group_id,), tenant_id=principal.tenant_id, effective_at=effective_at, relationship_kinds=tuple(relationship_kind), )[group_id] return TypedGroupMembershipResolutionItem( group=_typed_group_ref_item(resolved.group), effective_at=resolved.effective_at, decisions=[_decision_item(item) for item in resolved.decisions], identity_ids=list(resolved.identity_ids), ) __all__ = ["router"]