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
+211
View File
@@ -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(