Resolve typed IDM group audiences

This commit is contained in:
2026-08-02 14:44:41 +02:00
parent d1c994f3d0
commit 27a790976e
6 changed files with 491 additions and 12 deletions
+5
View File
@@ -46,6 +46,11 @@ Distribution Lists does not own:
IDM identity status is a lifecycle indicator. Business states used for IDM identity status is a lifecycle indicator. Business states used for
audience selection are typed groups, functions, or effective-dated audience selection are typed groups, functions, or effective-dated
relationships and must not be encoded by overloading identity lifecycle state. 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 ## Key Distinction
+6
View File
@@ -132,3 +132,9 @@ Addresses.
Expansion records the exact provider revisions and decisions. Templates, Expansion records the exact provider revisions and decisions. Templates,
Campaign, Files, Audit, Reporting, and Workflow consume that immutable result Campaign, Files, Audit, Reporting, and Workflow consume that immutable result
through capabilities and never import Distribution Lists internals. 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.
+46 -1
View File
@@ -10,6 +10,10 @@ from govoplan_core.core.organizations import (
CAPABILITY_ORGANIZATION_DIRECTORY, CAPABILITY_ORGANIZATION_DIRECTORY,
OrganizationDirectory, OrganizationDirectory,
) )
from govoplan_core.core.idm import (
CAPABILITY_IDM_RELATIONSHIPS,
IdmRelationshipDirectory,
)
from govoplan_dist_lists.backend.schemas import ( from govoplan_dist_lists.backend.schemas import (
ExplanationResponse, ExplanationResponse,
ProviderCatalogueResponse, ProviderCatalogueResponse,
@@ -164,10 +168,51 @@ def provider_catalogue(
unavailable.append( unavailable.append(
_unavailable("identity", "Identity search is not installed or enabled.") _unavailable("identity", "Identity search is not installed or enabled.")
) )
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.append(
_unavailable( _unavailable(
"idm", "idm",
"Typed-group selection is unavailable until an IDM group-directory capability is installed.", "Typed-group selection is unavailable until the IDM relationship capability is installed.",
severity="info", severity="info",
) )
) )
+189 -5
View File
@@ -50,7 +50,9 @@ from govoplan_core.core.identity import (
) )
from govoplan_core.core.idm import ( from govoplan_core.core.idm import (
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
CAPABILITY_IDM_RELATIONSHIPS,
IdmFunctionAssignmentDirectory, IdmFunctionAssignmentDirectory,
IdmRelationshipDirectory,
) )
from govoplan_core.core.organizations import ( from govoplan_core.core.organizations import (
CAPABILITY_ORGANIZATION_DIRECTORY, CAPABILITY_ORGANIZATION_DIRECTORY,
@@ -386,11 +388,7 @@ def _expand_entry(
if entry.kind == "idm_identity": if entry.kind == "idm_identity":
return _bounded_candidates(context, entry, _identity_recipients(context, entry)) return _bounded_candidates(context, entry, _identity_recipients(context, entry))
if entry.kind == "idm_group": if entry.kind == "idm_group":
return _bounded_candidates( return _bounded_candidates(context, entry, _idm_group_recipients(context, entry))
context,
entry,
[_provider_unavailable(entry, "IDM typed-group expansion is unavailable.")],
)
if entry.kind in {"organization_unit", "function", "effective_function_incumbent"}: if entry.kind in {"organization_unit", "function", "effective_function_incumbent"}:
return _bounded_candidates( return _bounded_candidates(
context, 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( def _organization_recipients(
context: _ExpansionContext, context: _ExpansionContext,
entry: DistributionListEntryRef, entry: DistributionListEntryRef,
+29 -1
View File
@@ -21,7 +21,10 @@ from govoplan_core.core.identity import (
CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_DIRECTORY,
CAPABILITY_IDENTITY_SEARCH, 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 ( from govoplan_core.core.module_guards import (
drop_table_retirement_provider, drop_table_retirement_provider,
persistent_table_uninstall_guard, persistent_table_uninstall_guard,
@@ -117,6 +120,24 @@ DOCUMENTATION = (
related_modules=("addresses", "campaigns", "policy"), related_modules=("addresses", "campaigns", "policy"),
metadata={"seed": True}, 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_DIRECTORY,
CAPABILITY_IDENTITY_SEARCH, CAPABILITY_IDENTITY_SEARCH,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
CAPABILITY_IDM_RELATIONSHIPS,
CAPABILITY_ORGANIZATION_DIRECTORY, CAPABILITY_ORGANIZATION_DIRECTORY,
CAPABILITY_DATAFLOW_DATASET_OUTPUT, CAPABILITY_DATAFLOW_DATASET_OUTPUT,
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
@@ -215,6 +237,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0", version_max_exclusive="0.2.0",
optional=True, optional=True,
), ),
ModuleInterfaceRequirement(
name=CAPABILITY_IDM_RELATIONSHIPS,
version_min="1.0.0",
version_max_exclusive="2.0.0",
optional=True,
),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
+211
View File
@@ -24,6 +24,18 @@ from govoplan_core.core.distribution_lists import (
DistributionListConflictError, DistributionListConflictError,
DistributionSourceReference, 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.base import Base
from govoplan_core.db.session import configure_database, reset_database from govoplan_core.db.session import configure_database, reset_database
from govoplan_dist_lists.backend.db.models import ( from govoplan_dist_lists.backend.db.models import (
@@ -33,6 +45,7 @@ from govoplan_dist_lists.backend.db.models import (
DistributionListSnapshot, DistributionListSnapshot,
) )
from govoplan_dist_lists.backend.capabilities import SqlDistributionListCapabilities 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.expansion import expand_distribution_list
from govoplan_dist_lists.backend.schemas import ( from govoplan_dist_lists.backend.schemas import (
DistributionListCreateRequest, DistributionListCreateRequest,
@@ -284,6 +297,135 @@ class _Registry:
return self.capabilities.get(name) 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): class DistributionListServiceTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.database = configure_database("sqlite:///:memory:") self.database = configure_database("sqlite:///:memory:")
@@ -521,6 +663,75 @@ class DistributionListServiceTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
get_distribution_list(session, principal("tenant-2"), first.id) 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: def test_dataflow_parameters_policy_and_missing_providers_are_explained(self) -> None:
dataflow = _Dataflow() dataflow = _Dataflow()
registry = _Registry( registry = _Registry(