Resolve typed IDM group audiences
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user