Resolve typed IDM group audiences
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,13 +168,54 @@ 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.")
|
||||||
)
|
)
|
||||||
unavailable.append(
|
relationship_directory = _typed_capability(
|
||||||
_unavailable(
|
registry,
|
||||||
"idm",
|
CAPABILITY_IDM_RELATIONSHIPS,
|
||||||
"Typed-group selection is unavailable until an IDM group-directory capability is installed.",
|
IdmRelationshipDirectory,
|
||||||
severity="info",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
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(
|
organizations = _typed_capability(
|
||||||
registry,
|
registry,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user