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