From 27a790976e4659c7c611528c569a6c81925a6da8 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sun, 2 Aug 2026 14:44:41 +0200 Subject: [PATCH] Resolve typed IDM group audiences --- README.md | 5 + docs/DISTRIBUTION_LISTS_ARCHITECTURE.md | 6 + src/govoplan_dist_lists/backend/catalogue.py | 57 ++++- src/govoplan_dist_lists/backend/expansion.py | 194 ++++++++++++++++- src/govoplan_dist_lists/backend/manifest.py | 30 ++- tests/test_service.py | 211 +++++++++++++++++++ 6 files changed, 491 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c980d15..37d061d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,11 @@ Distribution Lists does not own: IDM identity status is a lifecycle indicator. Business states used for audience selection are typed groups, functions, or effective-dated relationships and must not be encoded by overloading identity lifecycle state. +When the optional `idm.relationships` capability is present, `idm_group` +entries resolve at the requested effective time. Included and excluded links +retain stable decision codes, relationship revisions, external source +references, and provenance in previews and frozen snapshots. If IDM is absent, +the entry is reported as unavailable without affecting other entry types. ## Key Distinction diff --git a/docs/DISTRIBUTION_LISTS_ARCHITECTURE.md b/docs/DISTRIBUTION_LISTS_ARCHITECTURE.md index 52cfc46..e45ed5b 100644 --- a/docs/DISTRIBUTION_LISTS_ARCHITECTURE.md +++ b/docs/DISTRIBUTION_LISTS_ARCHITECTURE.md @@ -132,3 +132,9 @@ Addresses. Expansion records the exact provider revisions and decisions. Templates, Campaign, Files, Audit, Reporting, and Workflow consume that immutable result through capabilities and never import Distribution Lists internals. + +Typed-group expansion uses the optional `idm.relationships` contract. It asks +for all membership decisions at the expansion effective time rather than only +the accepted identities. This preserves future, expired, revoked, inactive, +and account-less exclusions alongside effective memberships, while keeping IDM +ORM records outside this module. diff --git a/src/govoplan_dist_lists/backend/catalogue.py b/src/govoplan_dist_lists/backend/catalogue.py index 578ecf2..e67dd27 100644 --- a/src/govoplan_dist_lists/backend/catalogue.py +++ b/src/govoplan_dist_lists/backend/catalogue.py @@ -10,6 +10,10 @@ from govoplan_core.core.organizations import ( CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory, ) +from govoplan_core.core.idm import ( + CAPABILITY_IDM_RELATIONSHIPS, + IdmRelationshipDirectory, +) from govoplan_dist_lists.backend.schemas import ( ExplanationResponse, ProviderCatalogueResponse, @@ -164,13 +168,54 @@ def provider_catalogue( unavailable.append( _unavailable("identity", "Identity search is not installed or enabled.") ) - unavailable.append( - _unavailable( - "idm", - "Typed-group selection is unavailable until an IDM group-directory capability is installed.", - severity="info", - ) + relationship_directory = _typed_capability( + registry, + CAPABILITY_IDM_RELATIONSHIPS, + IdmRelationshipDirectory, ) + if relationship_directory is not None: + try: + for group in relationship_directory.list_typed_groups( + tenant_id=principal.tenant_id, + query=query or None, + limit=bounded_limit, + ): + revision = group.source_revision or str(group.revision) + items.append( + ProviderOptionResponse( + key=f"idm-group:{group.id}", + kind="idm_group", + label=group.name, + description=f"{group.group_type} - {group.key}", + provider="idm", + source=SourceReferenceModel( + provider="idm", + resource_type="typed_group", + resource_id=group.id, + revision=revision, + label=group.name, + metadata={ + "key": group.key, + "group_type": group.group_type, + "source_provider": group.source_provider, + "source_resource_type": group.source_resource_type, + "source_resource_id": group.source_resource_id, + "properties": dict(group.properties), + "provenance": dict(group.provenance), + }, + ), + ) + ) + except (LookupError, PermissionError, ValueError) as exc: + unavailable.append(_unavailable("idm", str(exc))) + else: + unavailable.append( + _unavailable( + "idm", + "Typed-group selection is unavailable until the IDM relationship capability is installed.", + severity="info", + ) + ) organizations = _typed_capability( registry, diff --git a/src/govoplan_dist_lists/backend/expansion.py b/src/govoplan_dist_lists/backend/expansion.py index fc35a51..f1ccf23 100644 --- a/src/govoplan_dist_lists/backend/expansion.py +++ b/src/govoplan_dist_lists/backend/expansion.py @@ -50,7 +50,9 @@ from govoplan_core.core.identity import ( ) from govoplan_core.core.idm import ( CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, + CAPABILITY_IDM_RELATIONSHIPS, IdmFunctionAssignmentDirectory, + IdmRelationshipDirectory, ) from govoplan_core.core.organizations import ( CAPABILITY_ORGANIZATION_DIRECTORY, @@ -386,11 +388,7 @@ def _expand_entry( if entry.kind == "idm_identity": return _bounded_candidates(context, entry, _identity_recipients(context, entry)) if entry.kind == "idm_group": - return _bounded_candidates( - context, - entry, - [_provider_unavailable(entry, "IDM typed-group expansion is unavailable.")], - ) + return _bounded_candidates(context, entry, _idm_group_recipients(context, entry)) if entry.kind in {"organization_unit", "function", "effective_function_incumbent"}: return _bounded_candidates( context, @@ -1136,6 +1134,192 @@ def _identity_recipients( ] +def _idm_group_recipients( + context: _ExpansionContext, + entry: DistributionListEntryRef, +) -> list[DistributionRecipientRef]: + relationships = _typed_capability( + context.registry, + CAPABILITY_IDM_RELATIONSHIPS, + IdmRelationshipDirectory, + ) + identities = _typed_capability( + context.registry, + CAPABILITY_IDENTITY_DIRECTORY, + IdentityDirectory, + ) + if relationships is None or identities is None: + return [ + _provider_unavailable( + entry, + "IDM relationship and Identity directory capabilities are required.", + ) + ] + configured_kinds = entry.configuration.get("relationship_kinds", ("member",)) + relationship_kinds = ( + tuple(str(item) for item in configured_kinds if str(item).strip()) + if isinstance(configured_kinds, Sequence) + and not isinstance(configured_kinds, (str, bytes)) + else (str(configured_kinds),) + ) + try: + resolved = relationships.resolve_typed_group_memberships( + (entry.source.resource_id,), + tenant_id=context.principal.tenant_id, + effective_at=context.effective_at, + relationship_kinds=relationship_kinds or ("member",), + ).get(entry.source.resource_id) + except (LookupError, PermissionError, ValueError) as exc: + return [_unresolved(entry, "idm.group_resolution_failed", str(exc))] + if resolved is None: + return [_unresolved(entry, "idm.group_not_found", "Typed IDM group not found.")] + + actual_revision = resolved.group.source_revision or str(resolved.group.revision) + stale = bool( + entry.source.revision + and entry.source.revision != actual_revision + ) + context.evidence.append( + DistributionProviderEvidence( + provider="idm", + source=entry.source, + actual_revision=actual_revision, + stale=stale, + generated_at=context.effective_at, + details={ + "group_type": resolved.group.group_type, + "group_key": resolved.group.key, + "group_revision": resolved.group.revision, + "source_provider": resolved.group.source_provider, + "source_resource_type": resolved.group.source_resource_type, + "source_resource_id": resolved.group.source_resource_id, + "relationship_kinds": list(relationship_kinds), + }, + ) + ) + rows: list[DistributionRecipientRef] = [] + for decision in resolved.decisions: + relationship = decision.relationship + explanation = _explanation( + decision.code, + decision.explanation, + severity="info" if decision.included else "warning", + provider="idm", + source=entry.source, + ) + provenance = { + **_entry_provenance(entry), + "typed_group_id": resolved.group.id, + "typed_group_key": resolved.group.key, + "typed_group_type": resolved.group.group_type, + "typed_group_revision": resolved.group.revision, + "relationship_id": relationship.id, + "relationship_kind": relationship.relationship_kind, + "relationship_revision": relationship.revision, + "relationship_valid_from": ( + relationship.valid_from.isoformat() + if relationship.valid_from is not None + else None + ), + "relationship_valid_until": ( + relationship.valid_until.isoformat() + if relationship.valid_until is not None + else None + ), + "relationship_source_provider": relationship.source_provider, + "relationship_source_resource_type": relationship.source_resource_type, + "relationship_source_resource_id": relationship.source_resource_id, + "relationship_source_revision": relationship.source_revision, + "relationship_properties": dict(relationship.properties), + "relationship_provenance": dict(relationship.provenance), + "membership_decision": decision.code, + "membership_effective_at": resolved.effective_at.isoformat(), + } + if not decision.included: + rows.append( + DistributionRecipientRef( + recipient_key=f"identity:{relationship.subject_identity_id}", + display_name=relationship.subject_identity_id, + status="suppressed", + identity_id=relationship.subject_identity_id, + source_entry_ids=(entry.id,), + explanations=(explanation,), + provenance=provenance, + ) + ) + continue + identity = identities.get_identity(relationship.subject_identity_id) + if identity is None or identity.status != "active": + rows.append( + DistributionRecipientRef( + recipient_key=f"identity:{relationship.subject_identity_id}", + display_name=relationship.subject_identity_id, + status="unresolved", + identity_id=relationship.subject_identity_id, + source_entry_ids=(entry.id,), + explanations=( + _explanation( + "identity.not_active", + "Identity is missing or inactive.", + provider="identity", + source=entry.source, + ), + ), + provenance=provenance, + ) + ) + continue + account_id = identity.primary_account_id or next( + iter(identity.account_ids), None + ) + if account_id is None: + rows.append( + DistributionRecipientRef( + recipient_key=f"identity:{identity.id}", + display_name=identity.display_name or identity.id, + status="unresolved", + identity_id=identity.id, + source_entry_ids=(entry.id,), + explanations=( + _explanation( + "identity.no_account", + "Identity has no linked account.", + provider="identity", + source=entry.source, + ), + ), + provenance=provenance, + ) + ) + continue + rows.append( + DistributionRecipientRef( + recipient_key=f"identity:{identity.id}", + display_name=identity.display_name or identity.id, + status="usable", + channels=( + DistributionChannelCandidate( + channel="internal_mail", + target=account_id, + target_key=f"internal_mail:{account_id}", + source=entry.source, + decision_provenance={ + "relationship_id": relationship.id, + "relationship_revision": relationship.revision, + "decision": decision.code, + }, + ), + ), + identity_id=identity.id, + account_id=account_id, + source_entry_ids=(entry.id,), + explanations=(explanation,), + provenance=provenance, + ) + ) + return rows + + def _organization_recipients( context: _ExpansionContext, entry: DistributionListEntryRef, diff --git a/src/govoplan_dist_lists/backend/manifest.py b/src/govoplan_dist_lists/backend/manifest.py index 12e3f6a..c7c6c65 100644 --- a/src/govoplan_dist_lists/backend/manifest.py +++ b/src/govoplan_dist_lists/backend/manifest.py @@ -21,7 +21,10 @@ from govoplan_core.core.identity import ( CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH, ) -from govoplan_core.core.idm import CAPABILITY_IDM_FUNCTION_ASSIGNMENTS +from govoplan_core.core.idm import ( + CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, + CAPABILITY_IDM_RELATIONSHIPS, +) from govoplan_core.core.module_guards import ( drop_table_retirement_provider, persistent_table_uninstall_guard, @@ -117,6 +120,24 @@ DOCUMENTATION = ( related_modules=("addresses", "campaigns", "policy"), metadata={"seed": True}, ), + DocumentationTopic( + id=f"{MODULE_ID}.idm-group-resolution", + title="Effective IDM group audiences", + summary="Typed IDM groups expand into explainable, effective-dated identity recipients.", + body=( + "When IDM is enabled, an IDM group entry resolves through the idm.relationships " + "capability at the expansion effective time. Effective identities with linked " + "accounts become internal-mail candidates. Future, expired, revoked, inactive, " + "and account-less relationships remain in exclusion evidence with source revisions " + "and provenance. Without IDM, the provider is reported as unavailable and local or " + "other provider-backed lists continue to work." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("operator", "module_admin", "product_owner"), + related_modules=("identity", "idm"), + metadata={"seed": True}, + ), ) @@ -182,6 +203,7 @@ manifest = ModuleManifest( CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH, CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, + CAPABILITY_IDM_RELATIONSHIPS, CAPABILITY_ORGANIZATION_DIRECTORY, CAPABILITY_DATAFLOW_DATASET_OUTPUT, CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, @@ -215,6 +237,12 @@ manifest = ModuleManifest( version_max_exclusive="0.2.0", optional=True, ), + ModuleInterfaceRequirement( + name=CAPABILITY_IDM_RELATIONSHIPS, + version_min="1.0.0", + version_max_exclusive="2.0.0", + optional=True, + ), ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, diff --git a/tests/test_service.py b/tests/test_service.py index 7aaa4ed..720b6fe 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -24,6 +24,18 @@ from govoplan_core.core.distribution_lists import ( DistributionListConflictError, DistributionSourceReference, ) +from govoplan_core.core.identity import ( + CAPABILITY_IDENTITY_DIRECTORY, + IdentityAccountLinkRef, + IdentityRef, +) +from govoplan_core.core.idm import ( + CAPABILITY_IDM_RELATIONSHIPS, + IdentityRelationshipDecisionRef, + IdentityRelationshipRef, + TypedGroupMembershipResolutionRef, + TypedGroupRef, +) from govoplan_core.db.base import Base from govoplan_core.db.session import configure_database, reset_database from govoplan_dist_lists.backend.db.models import ( @@ -33,6 +45,7 @@ from govoplan_dist_lists.backend.db.models import ( DistributionListSnapshot, ) from govoplan_dist_lists.backend.capabilities import SqlDistributionListCapabilities +from govoplan_dist_lists.backend.catalogue import provider_catalogue from govoplan_dist_lists.backend.expansion import expand_distribution_list from govoplan_dist_lists.backend.schemas import ( DistributionListCreateRequest, @@ -284,6 +297,135 @@ class _Registry: return self.capabilities.get(name) +class _IdentityDirectory: + def __init__(self) -> None: + self.identity = IdentityRef( + id="identity-active", + display_name="Ada Example", + primary_account_id="account-active", + account_ids=("account-active",), + status="active", + ) + + def get_identity(self, identity_id: str): + return self.identity if identity_id == self.identity.id else None + + def identity_for_account(self, account_id: str): + return self.identity if account_id == "account-active" else None + + def identities_for_accounts(self, account_ids): + return (self.identity,) if "account-active" in account_ids else () + + def accounts_for_identity(self, identity_id: str): + if identity_id != self.identity.id: + return () + return ( + IdentityAccountLinkRef( + id="link-1", + identity_id=identity_id, + account_id="account-active", + is_primary=True, + ), + ) + + +class _RelationshipDirectory: + def __init__(self, effective_at: datetime) -> None: + self.group = TypedGroupRef( + 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={"sync_run_id": "sync-1"}, + revision=3, + ) + active = IdentityRelationshipRef( + id="relationship-active", + tenant_id="tenant-1", + relationship_kind="member", + subject_identity_id="identity-active", + target_group_id="group-1", + source_provider="ldap", + source_resource_type="membership", + source_resource_id="member:active", + source_revision="directory-42", + properties={"rank": 1}, + provenance={"sync_run_id": "sync-1"}, + revision=2, + ) + expired = IdentityRelationshipRef( + id="relationship-expired", + tenant_id="tenant-1", + relationship_kind="member", + subject_identity_id="identity-expired", + target_group_id="group-1", + valid_until=effective_at, + source_provider="ldap", + source_revision="directory-41", + ) + self.resolution = TypedGroupMembershipResolutionRef( + group=self.group, + effective_at=effective_at, + decisions=( + IdentityRelationshipDecisionRef( + relationship=active, + included=True, + code="relationship.effective", + explanation="The relationship is effective.", + identity_status="active", + ), + IdentityRelationshipDecisionRef( + relationship=expired, + included=False, + code="relationship.expired", + explanation="The relationship has expired.", + identity_status="active", + ), + ), + ) + + def get_typed_group(self, group_id, *, tenant_id=None): + return self.group if group_id == self.group.id else None + + def list_typed_groups(self, *, tenant_id, query=None, group_types=(), include_inactive=False, limit=100): + del group_types, include_inactive, limit + if tenant_id != "tenant-1" or (query and query.casefold() not in self.group.name.casefold()): + return () + return (self.group,) + + def identity_relationships_for_identity(self, identity_id, **kwargs): + return () + + def identity_relationships_for_identities(self, identity_ids, **kwargs): + return {identity_id: () for identity_id in identity_ids} + + def identity_relationships_for_group(self, group_id, **kwargs): + return tuple( + item.relationship + for item in self.resolution.decisions + if item.included + ) + + def identity_relationships_for_groups(self, group_ids, **kwargs): + return { + group_id: self.identity_relationships_for_group(group_id, **kwargs) + for group_id in group_ids + } + + def resolve_typed_group_memberships(self, group_ids, **kwargs): + return { + group_id: self.resolution + for group_id in group_ids + if group_id == self.group.id + } + + class DistributionListServiceTests(unittest.TestCase): def setUp(self) -> None: self.database = configure_database("sqlite:///:memory:") @@ -521,6 +663,75 @@ class DistributionListServiceTests(unittest.TestCase): with self.assertRaises(ValueError): get_distribution_list(session, principal("tenant-2"), first.id) + def test_idm_typed_group_expansion_preserves_decisions_and_provider_evidence(self) -> None: + effective_at = datetime(2026, 8, 2, 12, tzinfo=UTC) + relationships = _RelationshipDirectory(effective_at) + registry = _Registry( + { + CAPABILITY_IDM_RELATIONSHIPS: relationships, + CAPABILITY_IDENTITY_DIRECTORY: _IdentityDirectory(), + } + ) + with self.database.session() as session: + item, _ = create_distribution_list( + session, + principal(), + DistributionListCreateRequest.model_validate( + { + "name": "Permit holders", + "entries": [ + { + "entry_key": "permit-holders", + "kind": "idm_group", + "source": { + "provider": "idm", + "resource_type": "typed_group", + "resource_id": "group-1", + "revision": "directory-42", + }, + } + ], + } + ), + ) + session.flush() + result = expand_distribution_list( + session, + principal(), + registry=registry, + request=DistributionExpansionRequest( + list_id=item.id, + effective_at=effective_at, + ), + ) + catalogue = provider_catalogue( + session, + principal(), + registry=registry, + query="Permit", + ) + + self.assertEqual(("identity:identity-active",), tuple(row.recipient_key for row in result.recipients)) + self.assertEqual("account-active", result.recipients[0].account_id) + self.assertEqual( + "relationship.effective", + result.recipients[0].explanations[0].code, + ) + expired = next( + row for row in result.excluded if row.identity_id == "identity-expired" + ) + self.assertEqual("relationship.expired", expired.explanations[0].code) + self.assertEqual( + "directory-42", + result.provider_evidence[0].actual_revision, + ) + self.assertEqual( + "relationship-active", + result.recipients[0].provenance["relationship_id"], + ) + self.assertIn("idm-group:group-1", {row.key for row in catalogue.items}) + self.assertNotIn("idm", {row.provider for row in catalogue.unavailable_providers}) + def test_dataflow_parameters_policy_and_missing_providers_are_explained(self) -> None: dataflow = _Dataflow() registry = _Registry(