from __future__ import annotations from collections.abc import Sequence from datetime import datetime from sqlalchemy import or_ from govoplan_core.core.identity import IdentityDirectory from govoplan_core.core.idm import ( IdentityRelationshipDecisionRef, IdentityRelationshipRef, IdmRelationshipDirectory, TypedGroupMembershipResolutionRef, TypedGroupRef, ) from govoplan_core.db.session import get_database from govoplan_core.security.time import ensure_aware_utc, utc_now from govoplan_idm.backend.db.models import ( IdmIdentityRelationship, IdmTypedGroup, ) def typed_group_ref(item: IdmTypedGroup) -> TypedGroupRef: return TypedGroupRef( 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, # type: ignore[arg-type] 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 identity_relationship_ref( item: IdmIdentityRelationship, ) -> IdentityRelationshipRef: return IdentityRelationshipRef( 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, # type: ignore[arg-type] 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, ) class SqlIdmRelationshipDirectory(IdmRelationshipDirectory): def __init__(self, *, identities: IdentityDirectory) -> None: self._identities = identities def get_typed_group( self, group_id: str, *, tenant_id: str | None = None, ) -> TypedGroupRef | None: with get_database().session() as session: item = session.get(IdmTypedGroup, group_id) if item is None: return None if tenant_id is not None and item.tenant_id != tenant_id: raise ValueError("Typed group belongs to another tenant.") return typed_group_ref(item) def list_typed_groups( self, *, tenant_id: str, query: str | None = None, group_types: Sequence[str] = (), include_inactive: bool = False, limit: int = 100, ) -> tuple[TypedGroupRef, ...]: if limit < 1 or limit > 1000: raise ValueError("Typed-group limit must be between 1 and 1000.") with get_database().session() as session: statement = session.query(IdmTypedGroup).filter( IdmTypedGroup.tenant_id == tenant_id ) if not include_inactive: statement = statement.filter(IdmTypedGroup.status == "active") if group_types: statement = statement.filter( IdmTypedGroup.group_type.in_(tuple(dict.fromkeys(group_types))) ) if query and query.strip(): pattern = f"%{query.strip()}%" statement = statement.filter( or_( IdmTypedGroup.name.ilike(pattern), IdmTypedGroup.key.ilike(pattern), IdmTypedGroup.description.ilike(pattern), ) ) items = ( statement.order_by( IdmTypedGroup.name.asc(), IdmTypedGroup.id.asc(), ) .limit(limit) .all() ) return tuple(typed_group_ref(item) for item in items) def identity_relationships_for_identity( self, identity_id: str, *, tenant_id: str, effective_at: datetime | None = None, relationship_kinds: Sequence[str] = (), ) -> tuple[IdentityRelationshipRef, ...]: return tuple( self.identity_relationships_for_identities( (identity_id,), tenant_id=tenant_id, effective_at=effective_at, relationship_kinds=relationship_kinds, ).get(identity_id, ()) ) def identity_relationships_for_identities( self, identity_ids: Sequence[str], *, tenant_id: str, effective_at: datetime | None = None, relationship_kinds: Sequence[str] = (), ) -> dict[str, tuple[IdentityRelationshipRef, ...]]: requested = tuple(dict.fromkeys(identity_ids)) result: dict[str, list[IdentityRelationshipRef]] = { identity_id: [] for identity_id in requested } if not requested: return {} with get_database().session() as session: items = self._effective_relationships( session, tenant_id=tenant_id, effective_at=effective_at or utc_now(), identity_ids=requested, relationship_kinds=relationship_kinds, ) for item in items: result[item.subject_identity_id].append( identity_relationship_ref(item) ) return {key: tuple(value) for key, value in result.items()} def identity_relationships_for_group( self, group_id: str, *, tenant_id: str, effective_at: datetime | None = None, relationship_kinds: Sequence[str] = (), ) -> tuple[IdentityRelationshipRef, ...]: return tuple( self.identity_relationships_for_groups( (group_id,), tenant_id=tenant_id, effective_at=effective_at, relationship_kinds=relationship_kinds, ).get(group_id, ()) ) def identity_relationships_for_groups( self, group_ids: Sequence[str], *, tenant_id: str, effective_at: datetime | None = None, relationship_kinds: Sequence[str] = (), ) -> dict[str, tuple[IdentityRelationshipRef, ...]]: requested = tuple(dict.fromkeys(group_ids)) result: dict[str, list[IdentityRelationshipRef]] = { group_id: [] for group_id in requested } if not requested: return {} with get_database().session() as session: self._validate_group_tenants(session, requested, tenant_id) items = self._effective_relationships( session, tenant_id=tenant_id, effective_at=effective_at or utc_now(), group_ids=requested, relationship_kinds=relationship_kinds, ) for item in items: if item.target_group_id is not None: result[item.target_group_id].append( identity_relationship_ref(item) ) return {key: tuple(value) for key, value in result.items()} def resolve_typed_group_memberships( self, group_ids: Sequence[str], *, tenant_id: str, effective_at: datetime | None = None, relationship_kinds: Sequence[str] = ("member",), ) -> dict[str, TypedGroupMembershipResolutionRef]: requested = tuple(dict.fromkeys(group_ids)) if not requested: return {} moment = ensure_aware_utc(effective_at) or utc_now() with get_database().session() as session: groups = self._validate_group_tenants(session, requested, tenant_id) items = ( session.query(IdmIdentityRelationship) .filter( IdmIdentityRelationship.tenant_id == tenant_id, IdmIdentityRelationship.target_group_id.in_(requested), ) .order_by( IdmIdentityRelationship.created_at.asc(), IdmIdentityRelationship.id.asc(), ) ) if relationship_kinds: items = items.filter( IdmIdentityRelationship.relationship_kind.in_( tuple(dict.fromkeys(relationship_kinds)) ) ) rows = items.all() identities = { identity_id: self._identities.get_identity(identity_id) for identity_id in dict.fromkeys( item.subject_identity_id for item in rows ) } decisions: dict[str, list[IdentityRelationshipDecisionRef]] = { group_id: [] for group_id in requested } for item in rows: group = groups[item.target_group_id or ""] identity = identities[item.subject_identity_id] included, code, explanation = _membership_decision( item, group=group, identity_status=identity.status if identity is not None else None, effective_at=moment, ) decisions[group.id].append( IdentityRelationshipDecisionRef( relationship=identity_relationship_ref(item), included=included, code=code, explanation=explanation, identity_status=( identity.status if identity is not None else None ), ) ) return { group_id: TypedGroupMembershipResolutionRef( group=typed_group_ref(groups[group_id]), effective_at=moment, decisions=tuple(decisions[group_id]), ) for group_id in requested if group_id in groups } @staticmethod def _validate_group_tenants(session, group_ids, tenant_id): groups: dict[str, IdmTypedGroup] = {} for group_id in group_ids: item = session.get(IdmTypedGroup, group_id) if item is None: continue if item.tenant_id != tenant_id: raise ValueError("Typed group belongs to another tenant.") groups[item.id] = item return groups @staticmethod def _effective_relationships( session, *, tenant_id: str, effective_at: datetime, identity_ids: Sequence[str] = (), group_ids: Sequence[str] = (), relationship_kinds: Sequence[str] = (), ) -> tuple[IdmIdentityRelationship, ...]: query = session.query(IdmIdentityRelationship).filter( IdmIdentityRelationship.tenant_id == tenant_id, IdmIdentityRelationship.status == "active", or_( IdmIdentityRelationship.valid_from.is_(None), IdmIdentityRelationship.valid_from <= effective_at, ), or_( IdmIdentityRelationship.valid_until.is_(None), IdmIdentityRelationship.valid_until > effective_at, ), ) if identity_ids: query = query.filter( IdmIdentityRelationship.subject_identity_id.in_(identity_ids) ) if group_ids: query = query.filter( IdmIdentityRelationship.target_group_id.in_(group_ids) ) if relationship_kinds: query = query.filter( IdmIdentityRelationship.relationship_kind.in_( tuple(dict.fromkeys(relationship_kinds)) ) ) return tuple( query.order_by( IdmIdentityRelationship.created_at.asc(), IdmIdentityRelationship.id.asc(), ).all() ) def _membership_decision( item: IdmIdentityRelationship, *, group: IdmTypedGroup, identity_status: str | None, effective_at: datetime, ) -> tuple[bool, str, str]: if group.status != "active": return False, "group.inactive", "The typed group is inactive." if item.status == "revoked": return False, "relationship.revoked", "The relationship was revoked." if item.status != "active": return False, "relationship.inactive", "The relationship is not active." valid_from = ensure_aware_utc(item.valid_from) if valid_from is not None and valid_from > effective_at: return ( False, "relationship.not_yet_effective", "The relationship is not effective yet.", ) valid_until = ensure_aware_utc(item.valid_until) if valid_until is not None and valid_until <= effective_at: return False, "relationship.expired", "The relationship has expired." if identity_status is None: return False, "identity.missing", "The related identity no longer exists." if identity_status != "active": return ( False, "identity.not_active", f"The related identity lifecycle status is {identity_status}.", ) return True, "relationship.effective", "The relationship is effective." __all__ = [ "SqlIdmRelationshipDirectory", "identity_relationship_ref", "typed_group_ref", ]