From 6f6c45f6e2f6123b62e87a49435eaed25274b883 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sun, 2 Aug 2026 14:44:42 +0200 Subject: [PATCH] Implement typed effective identity relationships --- README.md | 11 + docs/TYPED_RELATIONSHIPS.md | 51 ++ .../backend/api/v1/relationships.py | 569 ++++++++++++++++++ src/govoplan_idm/backend/api/v1/routes.py | 2 + src/govoplan_idm/backend/api/v1/schemas.py | 135 +++++ .../backend/assignment_lifecycle.py | 89 +++ src/govoplan_idm/backend/db/models.py | 115 ++++ src/govoplan_idm/backend/manifest.py | 53 +- ...c2d3e4f5a6_typed_identity_relationships.py | 157 +++++ src/govoplan_idm/backend/relationships.py | 386 ++++++++++++ tests/test_assignment_expiry.py | 58 ++ tests/test_migrations.py | 58 ++ tests/test_relationships.py | 220 +++++++ 13 files changed, 1901 insertions(+), 3 deletions(-) create mode 100644 docs/TYPED_RELATIONSHIPS.md create mode 100644 src/govoplan_idm/backend/api/v1/relationships.py create mode 100644 src/govoplan_idm/backend/migrations/versions/b1c2d3e4f5a6_typed_identity_relationships.py create mode 100644 src/govoplan_idm/backend/relationships.py create mode 100644 tests/test_migrations.py create mode 100644 tests/test_relationships.py diff --git a/README.md b/README.md index b2bf14b..5392a18 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ vendor or protocol. - inbound synchronization from external identity-management systems - identity lifecycle import, update, disable, and reconciliation jobs - identity-to-organization-function assignment links inside GovOPlaN +- typed business groups and effective-dated identity relationship links - bridge views that combine identity and organization facts, such as identity candidates for organization function assignments - mapping external identities, accounts, groups, organizational units, @@ -73,6 +74,12 @@ assignment links: - `GET /api/v1/idm/organization-function-assignments` - `POST /api/v1/idm/organization-function-assignments` - `PATCH /api/v1/idm/organization-function-assignments/{assignment_id}` +- `GET|POST /api/v1/idm/typed-groups` +- `PATCH /api/v1/idm/typed-groups/{group_id}` +- `GET|POST /api/v1/idm/relationships` +- `PATCH /api/v1/idm/relationships/{relationship_id}` +- `POST /api/v1/idm/relationships/{relationship_id}/revoke` +- `GET /api/v1/idm/typed-groups/{group_id}/memberships` The candidate endpoint returns searchable identity/account candidates for IDM assignment forms. Assignment writes validate the identity/account link through @@ -139,6 +146,10 @@ shared `govoplan.idm.expire_assignments` task also emits the corresponding `idm.function_assignment.expired.v1` event. IDM records the event marker in the same database transaction, making repeated sweeps idempotent. +Typed group and relationship behavior, including effective-time resolution and +the provider-neutral capability consumed by Distribution Lists, is documented +in [Typed groups and effective-dated relationships](docs/TYPED_RELATIONSHIPS.md). + ## First Milestone The first useful milestone is a read-only synchronization preview: diff --git a/docs/TYPED_RELATIONSHIPS.md b/docs/TYPED_RELATIONSHIPS.md new file mode 100644 index 0000000..743d490 --- /dev/null +++ b/docs/TYPED_RELATIONSHIPS.md @@ -0,0 +1,51 @@ +# Typed groups and effective-dated relationships + +IDM owns tenant-scoped business group facts and the effective-dated links from +identities to those groups. Identity lifecycle state remains owned by Identity; +an active group membership never activates an identity and never grants an +application permission. + +## Contract + +The `idm.relationships` capability exposes immutable Core DTOs. Consumers can: + +- list typed groups without importing IDM persistence models; +- resolve one or many identities to their current relationships; +- resolve one or many groups to current identities at a caller-supplied time; +- request a membership decision set that includes future, expired, revoked, + inactive-group, and inactive-identity exclusions; +- retain source provider, external resource, revision, typed properties, and + provenance data in downstream evidence. + +Cross-tenant group references are rejected. The contract carries facts only and +does not imply a right, role, or permission. + +## Persistence and lifecycle + +`idm_typed_groups` stores the stable tenant/type/key identity and external source +reference. `idm_identity_relationships` stores one identity-to-group or +identity-to-identity link, its relationship kind, validity window, source, +properties, provenance, and optimistic revision. + +Create, change, revoke, and elapsed-validity transitions emit versioned platform +events: + +- `idm.typed_group.created.v1` +- `idm.typed_group.changed.v1` +- `idm.relationship.created.v1` +- `idm.relationship.changed.v1` +- `idm.relationship.revoked.v1` +- `idm.relationship.expired.v1` + +The existing IDM lifecycle worker claims an elapsed relationship and records its +event marker in the same transaction. Repeated or concurrent sweeps therefore do +not publish duplicate expiry events. + +## Distribution Lists + +When Distribution Lists is enabled, an `idm_group` entry resolves through this +capability. Every effective identity becomes an internal-mail candidate when an +active linked account exists. Every rejected relationship remains visible in the +expansion evidence with a stable reason code. Distribution Lists stores only the +provider reference and frozen expansion evidence, not IDM records. + diff --git a/src/govoplan_idm/backend/api/v1/relationships.py b/src/govoplan_idm/backend/api/v1/relationships.py new file mode 100644 index 0000000..63aea30 --- /dev/null +++ b/src/govoplan_idm/backend/api/v1/relationships.py @@ -0,0 +1,569 @@ +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"] diff --git a/src/govoplan_idm/backend/api/v1/routes.py b/src/govoplan_idm/backend/api/v1/routes.py index 343255a..f440e90 100644 --- a/src/govoplan_idm/backend/api/v1/routes.py +++ b/src/govoplan_idm/backend/api/v1/routes.py @@ -759,6 +759,8 @@ def _identity_candidate(identity: IdentityRef) -> OrganizationIdentityCandidate: from .function_changes import router as function_changes_router # noqa: E402 +from .relationships import router as relationships_router # noqa: E402 router.include_router(function_changes_router) +router.include_router(relationships_router) diff --git a/src/govoplan_idm/backend/api/v1/schemas.py b/src/govoplan_idm/backend/api/v1/schemas.py index a670910..a66c472 100644 --- a/src/govoplan_idm/backend/api/v1/schemas.py +++ b/src/govoplan_idm/backend/api/v1/schemas.py @@ -107,6 +107,141 @@ class IdmSettingsUpdateRequest(BaseModel): settings: dict[str, Any] | None = None +class TypedGroupItem(BaseModel): + id: str + tenant_id: str + key: str + name: str + group_type: str + description: str | None = None + status: Literal["active", "inactive"] + source_provider: str + source_resource_type: str | None = None + source_resource_id: str | None = None + source_revision: str | None = None + properties: dict[str, Any] + provenance: dict[str, Any] + revision: int + created_at: datetime | None = None + updated_at: datetime | None = None + + +class TypedGroupList(BaseModel): + groups: list[TypedGroupItem] + total: int + + +class TypedGroupCreateRequest(BaseModel): + key: str = Field(min_length=1, max_length=120) + name: str = Field(min_length=1, max_length=255) + group_type: str = Field(min_length=1, max_length=80) + description: str | None = Field(default=None, max_length=8_000) + source_provider: str = Field(default="local", min_length=1, max_length=80) + source_resource_type: str | None = Field(default=None, max_length=120) + source_resource_id: str | None = Field(default=None, max_length=255) + source_revision: str | None = Field(default=None, max_length=255) + properties: dict[str, Any] = Field(default_factory=dict) + provenance: dict[str, Any] = Field(default_factory=dict) + + +class TypedGroupUpdateRequest(BaseModel): + base_revision: int = Field(ge=1) + key: str | None = Field(default=None, min_length=1, max_length=120) + name: str | None = Field(default=None, min_length=1, max_length=255) + group_type: str | None = Field(default=None, min_length=1, max_length=80) + description: str | None = Field(default=None, max_length=8_000) + status: Literal["active", "inactive"] | None = None + source_provider: str | None = Field(default=None, min_length=1, max_length=80) + source_resource_type: str | None = Field(default=None, max_length=120) + source_resource_id: str | None = Field(default=None, max_length=255) + source_revision: str | None = Field(default=None, max_length=255) + properties: dict[str, Any] | None = None + provenance: dict[str, Any] | None = None + + +class IdentityRelationshipItem(BaseModel): + id: str + tenant_id: str + relationship_kind: str + subject_identity_id: str + target_group_id: str | None = None + related_identity_id: str | None = None + role: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + status: Literal["active", "revoked"] + revoked_at: datetime | None = None + revoked_by: str | None = None + revocation_reason: str | None = None + expired_event_at: datetime | None = None + source_provider: str + source_resource_type: str | None = None + source_resource_id: str | None = None + source_revision: str | None = None + properties: dict[str, Any] + provenance: dict[str, Any] + revision: int + created_at: datetime | None = None + updated_at: datetime | None = None + + +class IdentityRelationshipList(BaseModel): + relationships: list[IdentityRelationshipItem] + total: int + + +class IdentityRelationshipCreateRequest(BaseModel): + relationship_kind: str = Field(min_length=1, max_length=80) + subject_identity_id: str = Field(min_length=1, max_length=36) + target_group_id: str | None = Field(default=None, max_length=36) + related_identity_id: str | None = Field(default=None, max_length=36) + role: str | None = Field(default=None, max_length=120) + valid_from: datetime | None = None + valid_until: datetime | None = None + source_provider: str = Field(default="local", min_length=1, max_length=80) + source_resource_type: str | None = Field(default=None, max_length=120) + source_resource_id: str | None = Field(default=None, max_length=255) + source_revision: str | None = Field(default=None, max_length=255) + properties: dict[str, Any] = Field(default_factory=dict) + provenance: dict[str, Any] = Field(default_factory=dict) + + +class IdentityRelationshipUpdateRequest(BaseModel): + base_revision: int = Field(ge=1) + relationship_kind: str | None = Field(default=None, min_length=1, max_length=80) + target_group_id: str | None = Field(default=None, max_length=36) + related_identity_id: str | None = Field(default=None, max_length=36) + role: str | None = Field(default=None, max_length=120) + valid_from: datetime | None = None + valid_until: datetime | None = None + source_provider: str | None = Field(default=None, min_length=1, max_length=80) + source_resource_type: str | None = Field(default=None, max_length=120) + source_resource_id: str | None = Field(default=None, max_length=255) + source_revision: str | None = Field(default=None, max_length=255) + properties: dict[str, Any] | None = None + provenance: dict[str, Any] | None = None + + +class IdentityRelationshipRevokeRequest(BaseModel): + base_revision: int = Field(ge=1) + reason: str = Field(min_length=1, max_length=8_000) + + +class IdentityRelationshipDecisionItem(BaseModel): + relationship: IdentityRelationshipItem + included: bool + code: str + explanation: str + identity_status: str | None = None + + +class TypedGroupMembershipResolutionItem(BaseModel): + group: TypedGroupItem + effective_at: datetime + decisions: list[IdentityRelationshipDecisionItem] + identity_ids: list[str] + + FunctionAssignmentChangeKind = Literal["request", "grant"] FunctionAssignmentChangeAction = Literal[ "approve", diff --git a/src/govoplan_idm/backend/assignment_lifecycle.py b/src/govoplan_idm/backend/assignment_lifecycle.py index a4fcd2c..61ba7a1 100644 --- a/src/govoplan_idm/backend/assignment_lifecycle.py +++ b/src/govoplan_idm/backend/assignment_lifecycle.py @@ -22,6 +22,7 @@ from govoplan_idm.backend.assignment_events import emit_assignment_event from govoplan_idm.backend.db.models import ( IdmFunctionAssignmentChange, IdmFunctionAssignmentChangeEvent, + IdmIdentityRelationship, IdmOrganizationFunctionAssignment, ) from govoplan_idm.backend.function_assignment_changes import OPEN_STATES @@ -109,14 +110,102 @@ class SqlIdmAssignmentLifecycle: effective_at=now, limit=limit, ) + expired_relationship_ids = self._expire_relationships( + session, + tenant_id=tenant_id, + effective_at=now, + limit=limit, + ) return { "selected": len(candidates), "expired": len(expired_ids), "assignment_ids": expired_ids, "expired_changes": len(expired_change_ids), "change_ids": expired_change_ids, + "expired_relationships": len(expired_relationship_ids), + "relationship_ids": expired_relationship_ids, } + @staticmethod + def _expire_relationships( + session: Session, + *, + tenant_id: str | None, + effective_at: datetime, + limit: int, + ) -> list[str]: + query = session.query(IdmIdentityRelationship).filter( + IdmIdentityRelationship.status == "active", + IdmIdentityRelationship.valid_until.is_not(None), + IdmIdentityRelationship.valid_until <= effective_at, + IdmIdentityRelationship.expired_event_at.is_(None), + ) + if tenant_id is not None: + query = query.filter(IdmIdentityRelationship.tenant_id == tenant_id) + candidates = ( + query.order_by( + IdmIdentityRelationship.valid_until.asc(), + IdmIdentityRelationship.id.asc(), + ) + .limit(limit) + .all() + ) + expired_ids: list[str] = [] + for item in candidates: + claimed = ( + session.query(IdmIdentityRelationship) + .filter( + IdmIdentityRelationship.id == item.id, + IdmIdentityRelationship.status == "active", + IdmIdentityRelationship.valid_until.is_not(None), + IdmIdentityRelationship.valid_until <= effective_at, + IdmIdentityRelationship.expired_event_at.is_(None), + ) + .update( + { + IdmIdentityRelationship.expired_event_at: effective_at, + IdmIdentityRelationship.revision: ( + IdmIdentityRelationship.revision + 1 + ), + }, + synchronize_session=False, + ) + ) + if claimed != 1: + continue + session.refresh(item) + emit_platform_event( + session, + PlatformEvent( + type="idm.relationship.expired.v1", + module_id="idm", + payload={ + "relationship_kind": item.relationship_kind, + "target_group_id": item.target_group_id, + "related_identity_id": item.related_identity_id, + "revision": item.revision, + }, + actor=EventActorRef(type="system"), + tenant=EventTenantRef(id=item.tenant_id), + subject=EventObjectRef( + type="identity", id=item.subject_identity_id + ), + resource=EventObjectRef( + type="identity_relationship", id=item.id + ), + classification="internal", + ), + ) + invalidate_auth_principals( + session, + tenant_id=item.tenant_id, + source_module="idm", + resource_type="identity_relationship_expiry", + resource_id=item.id, + ) + expired_ids.append(item.id) + return expired_ids + def _expire_open_changes( self, session: Session, diff --git a/src/govoplan_idm/backend/db/models.py b/src/govoplan_idm/backend/db/models.py index b373bd0..6fc6545 100644 --- a/src/govoplan_idm/backend/db/models.py +++ b/src/govoplan_idm/backend/db/models.py @@ -6,6 +6,7 @@ from typing import Any from sqlalchemy import ( Boolean, + CheckConstraint, DateTime, ForeignKey, Index, @@ -60,6 +61,118 @@ class IdmOrganizationFunctionAssignment(Base, TimestampMixin): settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) +class IdmTypedGroup(Base, TimestampMixin): + __tablename__ = "idm_typed_groups" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "group_type", + "key", + name="uq_idm_typed_groups_tenant_type_key", + ), + Index( + "ix_idm_typed_groups_tenant_status_name", + "tenant_id", + "status", + "name", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + key: Mapped[str] = mapped_column(String(120), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + group_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), default="active", nullable=False) + source_provider: Mapped[str] = mapped_column(String(80), default="local", nullable=False) + source_resource_type: Mapped[str | None] = mapped_column(String(120), nullable=True) + source_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True) + properties: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + + @property + def strong_etag(self) -> str: + return strong_resource_etag("idm_typed_group", self.id, self.revision) + + +class IdmIdentityRelationship(Base, TimestampMixin): + __tablename__ = "idm_identity_relationships" + __table_args__ = ( + CheckConstraint( + "((target_group_id IS NOT NULL AND related_identity_id IS NULL) OR " + "(target_group_id IS NULL AND related_identity_id IS NOT NULL))", + name="ck_idm_relationship_exactly_one_target", + ), + CheckConstraint( + "valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from", + name="ck_idm_relationship_valid_window", + ), + Index( + "ix_idm_relationships_tenant_subject_effective", + "tenant_id", + "subject_identity_id", + "status", + "valid_from", + "valid_until", + ), + Index( + "ix_idm_relationships_tenant_group_effective", + "tenant_id", + "target_group_id", + "status", + "valid_from", + "valid_until", + ), + Index( + "ix_idm_relationships_expiry_due", + "status", + "expired_event_at", + "valid_until", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + relationship_kind: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + subject_identity_id: Mapped[str] = mapped_column( + ForeignKey("identity_identities.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + target_group_id: Mapped[str | None] = mapped_column( + ForeignKey("idm_typed_groups.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + related_identity_id: Mapped[str | None] = mapped_column( + ForeignKey("identity_identities.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + role: Mapped[str | None] = mapped_column(String(120), nullable=True) + valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + status: Mapped[str] = mapped_column(String(20), default="active", nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_by: Mapped[str | None] = mapped_column(String(36), nullable=True) + revocation_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + expired_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + source_provider: Mapped[str] = mapped_column(String(80), default="local", nullable=False) + source_resource_type: Mapped[str | None] = mapped_column(String(120), nullable=True) + source_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True) + properties: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + + @property + def strong_etag(self) -> str: + return strong_resource_etag("idm_identity_relationship", self.id, self.revision) + + class IdmTenantSettings(Base, TimestampMixin): __tablename__ = "idm_tenant_settings" @@ -243,7 +356,9 @@ class IdmFunctionAssignmentChangeEvent(Base): __all__ = [ "IdmFunctionAssignmentChange", "IdmFunctionAssignmentChangeEvent", + "IdmIdentityRelationship", "IdmOrganizationFunctionAssignment", "IdmTenantSettings", + "IdmTypedGroup", "new_uuid", ] diff --git a/src/govoplan_idm/backend/manifest.py b/src/govoplan_idm/backend/manifest.py index 55dae9a..8561b2e 100644 --- a/src/govoplan_idm/backend/manifest.py +++ b/src/govoplan_idm/backend/manifest.py @@ -15,6 +15,7 @@ from govoplan_core.core.idm import ( CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE, CAPABILITY_IDM_DIRECTORY, CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, + CAPABILITY_IDM_RELATIONSHIPS, ) from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH from govoplan_core.core.policy import ( @@ -58,6 +59,8 @@ IDM_READ_SCOPES = ( "idm:function_grant:create", "idm:function_change:decide", "idm:function_change:admin", + "idm:relationship:read", + "idm:relationship:write", "organizations:function:assign", ) @@ -127,6 +130,16 @@ PERMISSIONS = ( "Recover function assignment changes", "Inspect and recover blocked or failed function assignment workflows.", ), + _permission( + "idm:relationship:read", + "View typed identity relationships", + "View typed groups and effective-dated identity relationships.", + ), + _permission( + "idm:relationship:write", + "Manage typed identity relationships", + "Create, change, revoke, and synchronize typed groups and identity relationships.", + ), ) ROLE_TEMPLATES = ( @@ -145,6 +158,8 @@ ROLE_TEMPLATES = ( "idm:function_grant:create", "idm:function_change:decide", "idm:function_change:admin", + "idm:relationship:read", + "idm:relationship:write", ), ), RoleTemplate( @@ -187,6 +202,15 @@ def _assignment_lifecycle(context: ModuleContext) -> object: return SqlIdmAssignmentLifecycle(registry=context.registry) +def _relationship_directory(context: ModuleContext) -> object: + from govoplan_idm.backend.relationships import SqlIdmRelationshipDirectory + + identities = context.registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY) + if not isinstance(identities, IdentityDirectory): + raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}") + return SqlIdmRelationshipDirectory(identities=identities) + + manifest = ModuleManifest( id="idm", name="IDM", @@ -220,6 +244,10 @@ manifest = ModuleManifest( name=CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE, version=MODULE_VERSION, ), + ModuleInterfaceProvider( + name=CAPABILITY_IDM_RELATIONSHIPS, + version="1.0.0", + ), ModuleInterfaceProvider( name="idm.function_assignment_changes", version="1.0.0", @@ -255,6 +283,8 @@ manifest = ModuleManifest( idm_models.IdmTenantSettings, idm_models.IdmFunctionAssignmentChange, idm_models.IdmFunctionAssignmentChangeEvent, + idm_models.IdmTypedGroup, + idm_models.IdmIdentityRelationship, label="IDM", ), ), @@ -262,6 +292,7 @@ manifest = ModuleManifest( CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE: _assignment_lifecycle, CAPABILITY_IDM_DIRECTORY: _idm_directory, CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory, + CAPABILITY_IDM_RELATIONSHIPS: _relationship_directory, }, workflow_definitions=function_assignment_workflow_definitions( module_version=MODULE_VERSION, @@ -298,6 +329,22 @@ manifest = ModuleManifest( related_modules=("identity", "organizations", "access", "audit", "policy"), order=27, ), + DocumentationTopic( + id="idm.reference.typed-relationships", + title="Typed groups and effective relationships", + summary="IDM keeps business group membership separate from identity lifecycle status.", + body=( + "Typed groups and identity relationships are tenant-scoped, effective-dated facts. " + "Current, future, expired, and revoked links remain explainable, including external " + "directory source revisions and provenance. Consumers such as Distribution Lists use " + "the IDM relationship capability and never infer application permissions from membership." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("tenant_admin", "operator", "module_admin"), + related_modules=("identity", "organizations", "dist_lists"), + order=28, + ), DocumentationTopic( id="idm.workflow.assign-function-to-identity", title="Assign an organization function to an identity", @@ -322,10 +369,10 @@ manifest = ModuleManifest( documentation_ref="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", test_ref="tests/test_assignment_workflow.py", known_limits=("External directory provisioning and all authority-specific grant workflows are not reference-ready.",), - owned_concepts=("function assignment", "assignment delegation", "acting-for assignment", "assignment request"), + owned_concepts=("function assignment", "assignment delegation", "acting-for assignment", "assignment request", "typed group", "identity relationship"), non_owned_concepts=("identity", "organization function", "application role", "workflow runtime"), - recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",), - security_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",), + recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"), + security_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"), operations_docs=("README.md",), ), ) diff --git a/src/govoplan_idm/backend/migrations/versions/b1c2d3e4f5a6_typed_identity_relationships.py b/src/govoplan_idm/backend/migrations/versions/b1c2d3e4f5a6_typed_identity_relationships.py new file mode 100644 index 0000000..0aab6d6 --- /dev/null +++ b/src/govoplan_idm/backend/migrations/versions/b1c2d3e4f5a6_typed_identity_relationships.py @@ -0,0 +1,157 @@ +"""Add typed IDM groups and effective-dated relationships. + +Revision ID: b1c2d3e4f5a6 +Revises: a0b1c2d3e4f5 +Create Date: 2026-08-02 00:00:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "b1c2d3e4f5a6" +down_revision = "a0b1c2d3e4f5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "idm_typed_groups", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("key", sa.String(length=120), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("group_type", sa.String(length=80), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("source_provider", sa.String(length=80), nullable=False), + sa.Column("source_resource_type", sa.String(length=120), nullable=True), + sa.Column("source_resource_id", sa.String(length=255), nullable=True), + sa.Column("source_revision", sa.String(length=255), nullable=True), + sa.Column("properties", sa.JSON(), nullable=False), + sa.Column("provenance", sa.JSON(), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_idm_typed_groups")), + sa.UniqueConstraint( + "tenant_id", + "group_type", + "key", + name="uq_idm_typed_groups_tenant_type_key", + ), + ) + op.create_index( + op.f("ix_idm_typed_groups_tenant_id"), + "idm_typed_groups", + ["tenant_id"], + unique=False, + ) + op.create_index( + op.f("ix_idm_typed_groups_group_type"), + "idm_typed_groups", + ["group_type"], + unique=False, + ) + op.create_index( + "ix_idm_typed_groups_tenant_status_name", + "idm_typed_groups", + ["tenant_id", "status", "name"], + unique=False, + ) + + op.create_table( + "idm_identity_relationships", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("relationship_kind", sa.String(length=80), nullable=False), + sa.Column("subject_identity_id", sa.String(length=36), nullable=False), + sa.Column("target_group_id", sa.String(length=36), nullable=True), + sa.Column("related_identity_id", sa.String(length=36), nullable=True), + sa.Column("role", sa.String(length=120), nullable=True), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_by", sa.String(length=36), nullable=True), + sa.Column("revocation_reason", sa.Text(), nullable=True), + sa.Column("expired_event_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("source_provider", sa.String(length=80), nullable=False), + sa.Column("source_resource_type", sa.String(length=120), nullable=True), + sa.Column("source_resource_id", sa.String(length=255), nullable=True), + sa.Column("source_revision", sa.String(length=255), nullable=True), + sa.Column("properties", sa.JSON(), nullable=False), + sa.Column("provenance", sa.JSON(), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "((target_group_id IS NOT NULL AND related_identity_id IS NULL) OR " + "(target_group_id IS NULL AND related_identity_id IS NOT NULL))", + name="ck_idm_relationship_exactly_one_target", + ), + sa.CheckConstraint( + "valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from", + name="ck_idm_relationship_valid_window", + ), + sa.ForeignKeyConstraint( + ["subject_identity_id"], + ["identity_identities.id"], + name=op.f( + "fk_idm_identity_relationships_subject_identity_id_identity_identities" + ), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["related_identity_id"], + ["identity_identities.id"], + name=op.f( + "fk_idm_identity_relationships_related_identity_id_identity_identities" + ), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["target_group_id"], + ["idm_typed_groups.id"], + name=op.f( + "fk_idm_identity_relationships_target_group_id_idm_typed_groups" + ), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "id", name=op.f("pk_idm_identity_relationships") + ), + ) + for name, columns in ( + ("ix_idm_identity_relationships_tenant_id", ["tenant_id"]), + ("ix_idm_identity_relationships_relationship_kind", ["relationship_kind"]), + ("ix_idm_identity_relationships_subject_identity_id", ["subject_identity_id"]), + ("ix_idm_identity_relationships_target_group_id", ["target_group_id"]), + ("ix_idm_identity_relationships_related_identity_id", ["related_identity_id"]), + ( + "ix_idm_relationships_tenant_subject_effective", + ["tenant_id", "subject_identity_id", "status", "valid_from", "valid_until"], + ), + ( + "ix_idm_relationships_tenant_group_effective", + ["tenant_id", "target_group_id", "status", "valid_from", "valid_until"], + ), + ( + "ix_idm_relationships_expiry_due", + ["status", "expired_event_at", "valid_until"], + ), + ): + op.create_index( + name, + "idm_identity_relationships", + columns, + unique=False, + ) + + +def downgrade() -> None: + op.drop_table("idm_identity_relationships") + op.drop_table("idm_typed_groups") diff --git a/src/govoplan_idm/backend/relationships.py b/src/govoplan_idm/backend/relationships.py new file mode 100644 index 0000000..ec6ce91 --- /dev/null +++ b/src/govoplan_idm/backend/relationships.py @@ -0,0 +1,386 @@ +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", +] diff --git a/tests/test_assignment_expiry.py b/tests/test_assignment_expiry.py index 33f1ed5..5be48ea 100644 --- a/tests/test_assignment_expiry.py +++ b/tests/test_assignment_expiry.py @@ -12,7 +12,9 @@ from govoplan_idm.backend.assignment_lifecycle import SqlIdmAssignmentLifecycle from govoplan_idm.backend.db.models import ( IdmFunctionAssignmentChange, IdmFunctionAssignmentChangeEvent, + IdmIdentityRelationship, IdmOrganizationFunctionAssignment, + IdmTypedGroup, ) from govoplan_organizations.backend.db import models as organization_models # noqa: F401 @@ -26,6 +28,8 @@ class AssignmentExpiryTests(unittest.TestCase): IdmOrganizationFunctionAssignment.__table__, IdmFunctionAssignmentChange.__table__, IdmFunctionAssignmentChangeEvent.__table__, + IdmTypedGroup.__table__, + IdmIdentityRelationship.__table__, ChangeSequenceEntry.__table__, ], ) @@ -204,6 +208,60 @@ class AssignmentExpiryTests(unittest.TestCase): history = session.query(IdmFunctionAssignmentChangeEvent).all() self.assertEqual(["expired"], [item.action for item in history]) + def test_sweep_emits_relationship_expiry_once(self) -> None: + boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc) + with self.database.session() as session: + session.add( + IdmTypedGroup( + id="group-1", + tenant_id="tenant-1", + key="eligible", + name="Eligible", + group_type="business_status", + ) + ) + session.add( + IdmIdentityRelationship( + id="relationship-due", + tenant_id="tenant-1", + relationship_kind="member", + subject_identity_id="identity-1", + target_group_id="group-1", + valid_until=boundary - timedelta(seconds=1), + status="active", + properties={}, + provenance={}, + ) + ) + session.commit() + + events: list[PlatformEvent] = [] + bus = EventBus() + bus.subscribe("idm.relationship.expired.v1", events.append) + with self.database.SessionLocal() as session, event_bus_context(bus): + result = self.lifecycle.process_expired( + session, + tenant_id="tenant-1", + effective_at=boundary, + ) + session.commit() + repeated = self.lifecycle.process_expired( + session, + tenant_id="tenant-1", + effective_at=boundary, + ) + session.commit() + + self.assertEqual(["relationship-due"], result["relationship_ids"]) + self.assertEqual(1, result["expired_relationships"]) + self.assertEqual(0, repeated["expired_relationships"]) + self.assertEqual(1, len(events)) + self.assertEqual("identity-1", events[0].subject.id) + with self.database.session() as session: + item = session.get(IdmIdentityRelationship, "relationship-due") + self.assertEqual(2, item.revision) + self.assertIsNotNone(item.expired_event_at) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..246d50f --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, inspect + +from govoplan_core.db.migrations import migrate_database +from govoplan_identity.backend.manifest import get_manifest as identity_manifest +from govoplan_idm.backend.manifest import get_manifest as idm_manifest +from govoplan_organizations.backend.manifest import ( + get_manifest as organizations_manifest, +) + + +class IdmMigrationTests(unittest.TestCase): + def test_migrations_create_typed_relationship_tables_and_head(self) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-idm-migration-") as directory: + url = f"sqlite:///{Path(directory) / 'idm.db'}" + migrate_database( + database_url=url, + enabled_modules=("identity", "organizations", "idm"), + manifest_factories=( + identity_manifest, + organizations_manifest, + idm_manifest, + ), + ) + engine = create_engine(url) + try: + with engine.connect() as connection: + self.assertIn( + "b1c2d3e4f5a6", + set(MigrationContext.configure(connection).get_current_heads()), + ) + self.assertEqual( + { + "idm_function_assignment_change_events", + "idm_function_assignment_changes", + "idm_identity_relationships", + "idm_organization_function_assignments", + "idm_tenant_settings", + "idm_typed_groups", + }, + { + name + for name in inspect(connection).get_table_names() + if name.startswith("idm_") + }, + ) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_relationships.py b/tests/test_relationships.py new file mode 100644 index 0000000..616c2bd --- /dev/null +++ b/tests/test_relationships.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone + +from govoplan_core.core.identity import IdentityAccountLinkRef, IdentityRef +from govoplan_core.db.base import Base +from govoplan_core.db.session import configure_database, reset_database +from govoplan_identity.backend.db.models import CanonicalIdentity +from govoplan_idm.backend.db.models import ( + IdmIdentityRelationship, + IdmTypedGroup, +) +from govoplan_idm.backend.relationships import SqlIdmRelationshipDirectory + + +class StubIdentityDirectory: + def __init__(self, identities: tuple[IdentityRef, ...]) -> None: + self._identities = {item.id: item for item in identities} + + def get_identity(self, identity_id: str) -> IdentityRef | None: + return self._identities.get(identity_id) + + def identity_for_account(self, account_id: str) -> IdentityRef | None: + return None + + def identities_for_accounts(self, account_ids): + return () + + def accounts_for_identity(self, identity_id: str) -> tuple[IdentityAccountLinkRef, ...]: + return () + + +class IdmRelationshipDirectoryTests(unittest.TestCase): + def setUp(self) -> None: + self.database = configure_database("sqlite:///:memory:") + Base.metadata.create_all( + self.database.engine, + tables=[ + CanonicalIdentity.__table__, + IdmTypedGroup.__table__, + IdmIdentityRelationship.__table__, + ], + ) + identities = ( + IdentityRef(id="identity-active", display_name="Active", status="active"), + IdentityRef(id="identity-future", display_name="Future", status="active"), + IdentityRef(id="identity-expired", display_name="Expired", status="active"), + IdentityRef(id="identity-revoked", display_name="Revoked", status="active"), + IdentityRef(id="identity-suspended", display_name="Suspended", status="suspended"), + ) + self.directory = SqlIdmRelationshipDirectory( + identities=StubIdentityDirectory(identities) # type: ignore[arg-type] + ) + with self.database.session() as session: + session.add_all( + CanonicalIdentity( + id=item.id, + display_name=item.display_name, + source="local", + is_active=item.status == "active", + settings={}, + ) + for item in identities + ) + session.add_all( + ( + IdmTypedGroup( + id="group-1", + tenant_id="tenant-1", + key="permit-holder", + name="Permit holders", + group_type="business_status", + source_provider="ldap", + source_resource_type="group", + source_resource_id="cn=permit-holders,dc=example", + source_revision="directory-42", + properties={"classification": "resident"}, + provenance={"connector_id": "ldap-1"}, + ), + IdmTypedGroup( + id="group-other", + tenant_id="tenant-2", + key="other", + name="Other tenant", + group_type="business_status", + ), + ) + ) + session.commit() + + def tearDown(self) -> None: + reset_database(dispose=True) + + @staticmethod + def _relationship( + relationship_id: str, + identity_id: str, + *, + boundary: datetime, + valid_from: datetime | None = None, + valid_until: datetime | None = None, + status: str = "active", + ) -> IdmIdentityRelationship: + return IdmIdentityRelationship( + id=relationship_id, + tenant_id="tenant-1", + relationship_kind="member", + subject_identity_id=identity_id, + target_group_id="group-1", + valid_from=valid_from, + valid_until=valid_until, + status=status, + revoked_at=boundary if status == "revoked" else None, + revoked_by="account-1" if status == "revoked" else None, + revocation_reason="No longer eligible" if status == "revoked" else None, + source_provider="ldap", + source_resource_type="membership", + source_resource_id=f"member:{identity_id}", + source_revision="directory-42", + properties={"rank": 1}, + provenance={"sync_run_id": "sync-1"}, + revision=1, + ) + + def test_resolution_explains_current_future_expired_revoked_and_lifecycle(self) -> None: + boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc) + with self.database.session() as session: + session.add_all( + ( + self._relationship("active", "identity-active", boundary=boundary), + self._relationship( + "future", + "identity-future", + boundary=boundary, + valid_from=boundary + timedelta(days=1), + ), + self._relationship( + "expired", + "identity-expired", + boundary=boundary, + valid_until=boundary, + ), + self._relationship( + "revoked", + "identity-revoked", + boundary=boundary, + status="revoked", + ), + self._relationship( + "suspended", + "identity-suspended", + boundary=boundary, + ), + ) + ) + session.commit() + + resolved = self.directory.resolve_typed_group_memberships( + ("group-1",), + tenant_id="tenant-1", + effective_at=boundary, + )["group-1"] + + self.assertEqual(("identity-active",), resolved.identity_ids) + self.assertEqual( + { + "active": "relationship.effective", + "future": "relationship.not_yet_effective", + "expired": "relationship.expired", + "revoked": "relationship.revoked", + "suspended": "identity.not_active", + }, + {item.relationship.id: item.code for item in resolved.decisions}, + ) + self.assertEqual("directory-42", resolved.group.source_revision) + self.assertEqual("ldap", resolved.decisions[0].relationship.source_provider) + + def test_forward_reverse_batch_queries_return_only_effective_relationships(self) -> None: + boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc) + with self.database.session() as session: + session.add_all( + ( + self._relationship("active", "identity-active", boundary=boundary), + self._relationship( + "future", + "identity-future", + boundary=boundary, + valid_from=boundary + timedelta(days=1), + ), + ) + ) + session.commit() + + forward = self.directory.identity_relationships_for_identities( + ("identity-active", "identity-future"), + tenant_id="tenant-1", + effective_at=boundary, + ) + reverse = self.directory.identity_relationships_for_groups( + ("group-1",), + tenant_id="tenant-1", + effective_at=boundary, + ) + + self.assertEqual(("active",), tuple(item.id for item in forward["identity-active"])) + self.assertEqual((), forward["identity-future"]) + self.assertEqual(("active",), tuple(item.id for item in reverse["group-1"])) + + def test_cross_tenant_group_references_are_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "another tenant"): + self.directory.get_typed_group("group-other", tenant_id="tenant-1") + with self.assertRaisesRegex(ValueError, "another tenant"): + self.directory.identity_relationships_for_group( + "group-other", tenant_id="tenant-1" + ) + + +if __name__ == "__main__": + unittest.main()