feat(postbox): enforce unified inbox separation

This commit is contained in:
2026-08-20 04:22:29 +02:00
parent 174ee97719
commit 41ea8d8e23
16 changed files with 1029 additions and 25 deletions
+10
View File
@@ -42,6 +42,10 @@ class PostboxMigrationTests(unittest.TestCase):
"govoplan_postbox.backend.migrations.versions."
"a7c1e4f8b2d6_v016_protection_transitions"
)
grouping_policy_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"d8b4f1a6c9e2_v017_grouping_policy"
)
engine = create_engine("sqlite:///:memory:")
try:
with engine.begin() as connection:
@@ -54,6 +58,7 @@ class PostboxMigrationTests(unittest.TestCase):
scope_original = scope_migration.op
portal_original = portal_migration.op
transition_original = transition_migration.op
grouping_policy_original = grouping_policy_migration.op
migration.op = operations
route_migration.op = operations
occ_migration.op = operations
@@ -62,6 +67,7 @@ class PostboxMigrationTests(unittest.TestCase):
scope_migration.op = operations
portal_migration.op = operations
transition_migration.op = operations
grouping_policy_migration.op = operations
try:
migration.upgrade()
route_migration.upgrade()
@@ -71,6 +77,7 @@ class PostboxMigrationTests(unittest.TestCase):
scope_migration.upgrade()
portal_migration.upgrade()
transition_migration.upgrade()
grouping_policy_migration.upgrade()
tables = set(inspect(connection).get_table_names())
self.assertIn("postboxes", tables)
self.assertIn("postbox_messages", tables)
@@ -110,6 +117,7 @@ class PostboxMigrationTests(unittest.TestCase):
"scope_structure_id",
"scope_relation_type_ids",
"portal_visible",
"grouping_policy",
}.issubset(template_revision_columns)
)
self.assertIn("authoring_key", message_columns)
@@ -134,6 +142,7 @@ class PostboxMigrationTests(unittest.TestCase):
self.assertTrue(
{"execute_after", "processed_at"}.issubset(route_columns)
)
grouping_policy_migration.downgrade()
transition_migration.downgrade()
portal_migration.downgrade()
scope_migration.downgrade()
@@ -158,6 +167,7 @@ class PostboxMigrationTests(unittest.TestCase):
scope_migration.op = scope_original
portal_migration.op = portal_original
transition_migration.op = transition_original
grouping_policy_migration.op = grouping_policy_original
finally:
engine.dispose()
+207
View File
@@ -546,6 +546,7 @@ class PostboxServiceTests(unittest.TestCase):
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
protection_policy: dict[str, object] | None = None,
grouping_policy: dict[str, object] | None = None,
) -> Postbox:
return self.service.create_exact_postbox(
session,
@@ -560,6 +561,7 @@ class PostboxServiceTests(unittest.TestCase):
encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
protection_policy=protection_policy,
grouping_policy=grouping_policy,
)
def _routing_policy(
@@ -2091,6 +2093,211 @@ class PostboxServiceTests(unittest.TestCase):
}
self.assertEqual({parent.id, child.id}, visible_ids)
def test_grouping_policy_enforces_function_and_classification_separation(
self,
) -> None:
child_assignment = OrganizationFunctionAssignmentRef(
id="assignment-child",
tenant_id="tenant-1",
identity_id="identity-1",
account_id="account-1",
function_id="function-child",
organization_unit_id="unit-child",
source="delegated",
delegated_from_assignment_id="assignment-1",
)
self.idm.assignments.extend((self.assignment, child_assignment))
privileged_actor = replace(
self.actor,
authorized_classifications=frozenset(
{"public", "internal", "confidential"}
),
)
with Session(self.engine) as session:
parent = self._create_exact(
session,
grouping_policy={
"mode": "same_classification",
"reason": "Confidential responsibilities remain partitioned.",
},
)
child = self.service.create_exact_postbox(
session,
tenant_id="tenant-1",
name="Service Desk / Delegated complaints",
organization_unit_id="unit-child",
function_id="function-child",
address_key=None,
description=None,
classification="confidential",
actor_id="admin-1",
)
with self.assertRaisesRegex(
PostboxError,
"classification_separation_required",
):
self.service.save_grouping(
session,
tenant_id="tenant-1",
actor=privileged_actor,
grouping_id=None,
name="Mixed classification",
is_default=False,
postbox_ids=(parent.id, child.id),
)
grouping = self.service.save_grouping(
session,
tenant_id="tenant-1",
actor=privileged_actor,
grouping_id=None,
name="Same responsibility",
is_default=True,
postbox_ids=(parent.id,),
)
session.commit()
self.service.update_grouping_policy(
session,
tenant_id="tenant-1",
postbox_id=parent.id,
grouping_policy={
"mode": "separate",
"reason": "This function must remain a dedicated inbox.",
},
actor_id="admin-1",
expected_revision=parent.resource_revision,
)
session.commit()
with self.assertRaisesRegex(PostboxError, "source_requires_separation"):
self.service.list_messages(
session,
tenant_id="tenant-1",
postbox_ids=(parent.id, child.id),
actor=privileged_actor,
)
self.assertEqual(
(parent.id,),
tuple(source.postbox_id for source in grouping.sources),
)
def test_grouping_pagination_is_stable_and_delegation_expiry_hides_source(
self,
) -> None:
delegated = OrganizationFunctionAssignmentRef(
id="assignment-child",
tenant_id="tenant-1",
identity_id="identity-1",
account_id="account-1",
function_id="function-child",
organization_unit_id="unit-child",
source="delegated",
delegated_from_assignment_id="assignment-1",
)
self.idm.assignments.extend((self.assignment, delegated))
with Session(self.engine) as session:
parent = self._create_exact(session)
child = self.service.create_exact_postbox(
session,
tenant_id="tenant-1",
name="Service Desk / Delegated intake",
organization_unit_id="unit-child",
function_id="function-child",
address_key=None,
description=None,
classification="internal",
actor_id="admin-1",
)
grouping = self.service.save_grouping(
session,
tenant_id="tenant-1",
actor=self.actor,
grouping_id=None,
name="Delegated work",
is_default=True,
postbox_ids=(parent.id, child.id),
)
focused_grouping = self.service.save_grouping(
session,
tenant_id="tenant-1",
actor=self.actor,
grouping_id=None,
name="Delegated intake only",
is_default=False,
postbox_ids=(child.id,),
)
self.assertEqual(
(child.id,),
tuple(source.postbox_id for source in focused_grouping.sources),
)
for index, postbox in enumerate((parent, child, parent, child), start=1):
self.service.deliver(
session,
PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=postbox.id),
producer_module="tests",
producer_resource_type="stable_page",
producer_resource_id=str(index),
idempotency_key=f"stable-page-{index}",
subject=f"Message {index}",
classification="internal",
),
)
boundary = utc_now()
session.query(PostboxMessage).update(
{PostboxMessage.delivered_at: boundary}
)
session.commit()
first = self.service.list_messages(
session,
tenant_id="tenant-1",
postbox_ids=(parent.id, child.id),
actor=self.actor,
limit=2,
offset=0,
)
second = self.service.list_messages(
session,
tenant_id="tenant-1",
postbox_ids=(parent.id, child.id),
actor=self.actor,
limit=2,
offset=2,
)
repeated = self.service.list_messages(
session,
tenant_id="tenant-1",
postbox_ids=(parent.id, child.id),
actor=self.actor,
limit=2,
offset=0,
)
self.assertEqual(
[item.id for item in first], [item.id for item in repeated]
)
self.assertFalse({item.id for item in first} & {item.id for item in second})
self.idm.assignments = [
self.assignment,
replace(delegated, status="expired"),
]
visible_ids = {
item.id
for item in self.service.list_visible_postboxes(
session,
tenant_id="tenant-1",
actor=self.actor,
)
}
self.assertEqual({parent.id}, visible_ids)
self.assertEqual(
(parent.id, child.id),
tuple(source.postbox_id for source in grouping.sources),
)
if __name__ == "__main__":
unittest.main()