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
+189 -5
View File
@@ -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,