feat(postbox): add governed content protection profiles

This commit is contained in:
2026-08-20 03:42:58 +02:00
parent 15d93aaa25
commit 174ee97719
16 changed files with 3917 additions and 383 deletions
+13 -6
View File
@@ -38,6 +38,10 @@ class PostboxMigrationTests(unittest.TestCase):
"govoplan_postbox.backend.migrations.versions."
"f2a5c8e1b4d7_v015_portal_visibility"
)
transition_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"a7c1e4f8b2d6_v016_protection_transitions"
)
engine = create_engine("sqlite:///:memory:")
try:
with engine.begin() as connection:
@@ -49,6 +53,7 @@ class PostboxMigrationTests(unittest.TestCase):
protection_original = protection_migration.op
scope_original = scope_migration.op
portal_original = portal_migration.op
transition_original = transition_migration.op
migration.op = operations
route_migration.op = operations
occ_migration.op = operations
@@ -56,6 +61,7 @@ class PostboxMigrationTests(unittest.TestCase):
protection_migration.op = operations
scope_migration.op = operations
portal_migration.op = operations
transition_migration.op = operations
try:
migration.upgrade()
route_migration.upgrade()
@@ -64,11 +70,14 @@ class PostboxMigrationTests(unittest.TestCase):
protection_migration.upgrade()
scope_migration.upgrade()
portal_migration.upgrade()
transition_migration.upgrade()
tables = set(inspect(connection).get_table_names())
self.assertIn("postboxes", tables)
self.assertIn("postbox_messages", tables)
self.assertIn("postbox_deliveries", tables)
self.assertIn("postbox_access_events", tables)
self.assertIn("postbox_protection_transitions", tables)
self.assertIn("postbox_protection_transition_items", tables)
message_columns = {
column["name"]
for column in inspect(connection).get_columns(
@@ -120,15 +129,12 @@ class PostboxMigrationTests(unittest.TestCase):
)
route_columns = {
column["name"]
for column in inspect(connection).get_columns(
"postbox_routes"
)
for column in inspect(connection).get_columns("postbox_routes")
}
self.assertTrue(
{"execute_after", "processed_at"}.issubset(
route_columns
)
{"execute_after", "processed_at"}.issubset(route_columns)
)
transition_migration.downgrade()
portal_migration.downgrade()
scope_migration.downgrade()
protection_migration.downgrade()
@@ -151,6 +157,7 @@ class PostboxMigrationTests(unittest.TestCase):
protection_migration.op = protection_original
scope_migration.op = scope_original
portal_migration.op = portal_original
transition_migration.op = transition_original
finally:
engine.dispose()
+368 -51
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import hashlib
import unittest
from dataclasses import replace
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import patch
@@ -49,6 +51,8 @@ from govoplan_postbox.backend.db.models import (
PostboxMessage,
PostboxMessageReceipt,
PostboxParticipant,
PostboxProtectionTransition,
PostboxProtectionTransitionItem,
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
@@ -64,6 +68,8 @@ POSTBOX_TABLES = (
Postbox.__table__,
PostboxBinding.__table__,
PostboxMessage.__table__,
PostboxProtectionTransition.__table__,
PostboxProtectionTransitionItem.__table__,
PostboxParticipant.__table__,
PostboxAttachmentReference.__table__,
PostboxDelivery.__table__,
@@ -87,7 +93,9 @@ class FakeIdentityDirectory:
)
def identities_for_accounts(self, account_ids):
return tuple(self.identity_for_account(account_id) for account_id in account_ids)
return tuple(
self.identity_for_account(account_id) for account_id in account_ids
)
def accounts_for_identity(self, identity_id: str):
return ()
@@ -335,8 +343,7 @@ class FakeOrganizationDirectory:
if function.function_type_id == function_type_id
and (
not organization_unit_ids
or function.organization_unit_id
in organization_unit_ids
or function.organization_unit_id in organization_unit_ids
)
),
)
@@ -355,8 +362,7 @@ class FakeOrganizationDirectory:
matches=tuple(
unit
for unit in self.units.values()
if unit.tenant_id == tenant_id
and unit.unit_type_id == unit_type_id
if unit.tenant_id == tenant_id and unit.unit_type_id == unit_type_id
),
)
@@ -457,9 +463,7 @@ class FakeOrganizationDirectory:
root=root,
matches=matches,
cycle_detected=self.cycle_detected,
depth_limited=(
structure_id == self.structure.id and max_depth < 2
),
depth_limited=(structure_id == self.structure.id and max_depth < 2),
)
)
return tuple(results)
@@ -529,15 +533,20 @@ class PostboxServiceTests(unittest.TestCase):
self.actor = PostboxActorRef(
account_id="account-1",
identity_id="identity-1",
authorized_actions=frozenset(
{"discover", "read", "send", "acknowledge"}
),
authorized_actions=frozenset({"discover", "read", "send", "acknowledge"}),
)
def tearDown(self) -> None:
self.engine.dispose()
def _create_exact(self, session: Session) -> Postbox:
def _create_exact(
self,
session: Session,
*,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
protection_policy: dict[str, object] | None = None,
) -> Postbox:
return self.service.create_exact_postbox(
session,
tenant_id="tenant-1",
@@ -548,6 +557,9 @@ class PostboxServiceTests(unittest.TestCase):
description=None,
classification="internal",
actor_id="admin-1",
encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
protection_policy=protection_policy,
)
def _routing_policy(
@@ -575,11 +587,7 @@ class PostboxServiceTests(unittest.TestCase):
"max_retention_days": 30,
},
"attention": {
"mode": (
"vacancy_escalation"
if vacancy_escalation
else "none"
),
"mode": ("vacancy_escalation" if vacancy_escalation else "none"),
"delay_minutes": 1 if vacancy_escalation else None,
},
"shared_visibility": {"mode": "none"},
@@ -830,7 +838,9 @@ class PostboxServiceTests(unittest.TestCase):
),
)
def test_template_revision_is_immutable_and_materialization_idempotent(self) -> None:
def test_template_revision_is_immutable_and_materialization_idempotent(
self,
) -> None:
with Session(self.engine) as session:
template = self.service.create_template(
session,
@@ -957,6 +967,7 @@ class PostboxServiceTests(unittest.TestCase):
routing_policy={},
encryption_profile="plaintext_v1",
encryption_vault_id=None,
protection_policy={},
context_key=None,
limit=200,
)
@@ -985,6 +996,7 @@ class PostboxServiceTests(unittest.TestCase):
routing_policy={},
encryption_profile="plaintext_v1",
encryption_vault_id=None,
protection_policy={},
context_key=None,
limit=200,
)
@@ -1014,6 +1026,7 @@ class PostboxServiceTests(unittest.TestCase):
routing_policy={},
encryption_profile="plaintext_v1",
encryption_vault_id=None,
protection_policy={},
context_key=None,
limit=200,
)
@@ -1067,9 +1080,7 @@ class PostboxServiceTests(unittest.TestCase):
[item.slug for item in catalog.templates],
)
north = next(
unit
for unit in catalog.organization_units
if unit.id == "unit-1"
unit for unit in catalog.organization_units if unit.id == "unit-1"
)
self.assertEqual(
["function-1"],
@@ -1309,7 +1320,11 @@ class PostboxServiceTests(unittest.TestCase):
self.idm.assignments.append(self.assignment)
expires_at = utc_now() + timedelta(days=1)
with Session(self.engine) as session:
postbox = self._create_exact(session)
postbox = self._create_exact(
session,
encryption_profile="external_e2ee_v1",
protection_policy={"external_recipient_assurance": "email_otp"},
)
delivered = self.service.deliver(
session,
PostboxDeliveryRequest(
@@ -1340,6 +1355,7 @@ class PostboxServiceTests(unittest.TestCase):
assurance_profile="email-otp",
),
),
metadata={"content_digest": "sha256:" + "a" * 64},
),
)
@@ -1361,9 +1377,7 @@ class PostboxServiceTests(unittest.TestCase):
assert message.external_recipient_tokens[0].expires_at is not None
self.assertEqual(
expires_at.replace(microsecond=0),
message.external_recipient_tokens[0].expires_at.replace(
microsecond=0
),
message.external_recipient_tokens[0].expires_at.replace(microsecond=0),
)
def test_server_envelope_body_is_not_persisted_in_plaintext_and_fails_closed(
@@ -1445,14 +1459,330 @@ class PostboxServiceTests(unittest.TestCase):
actor=self.actor,
)
def test_future_only_protection_transition_changes_new_message_policy(self) -> None:
with Session(self.engine) as session:
postbox = self._create_exact(
session,
protection_policy={
"handover_authority": "user_consent",
"handover_quorum": 1,
},
)
transition = self.service.create_protection_transition(
session,
tenant_id="tenant-1",
postbox_id=postbox.id,
idempotency_key="future-e2ee",
expected_revision=postbox.resource_revision,
target_profile="external_e2ee_v1",
target_vault_id=None,
history_mode="future_only",
authority_mode="user_consent",
required_quorum=1,
user_consent_refs=("consent:user-1",),
institutional_authorization_refs=(),
reason="Use client-held keys for future messages.",
actor_id="admin-1",
)
self.assertEqual("completed", transition.state)
self.assertEqual(0, transition.message_count)
self.assertEqual("external_e2ee_v1", postbox.encryption_profile)
self.assertEqual(2, postbox.key_epoch)
def test_client_transform_completes_plaintext_to_e2ee_history(self) -> None:
self.idm.assignments.append(self.assignment)
plaintext = "A governed message"
digest = "sha256:" + hashlib.sha256(plaintext.encode()).hexdigest()
with Session(self.engine) as session:
postbox = self._create_exact(
session,
protection_policy={
"handover_authority": "user_consent",
"handover_quorum": 1,
},
)
delivered = self.service.deliver(
session,
PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=postbox.id),
producer_module="campaigns",
producer_resource_type="campaign_recipient",
producer_resource_id="recipient-1",
idempotency_key="plaintext-history",
subject="Governed history",
body_text=plaintext,
),
)
transition = self.service.create_protection_transition(
session,
tenant_id="tenant-1",
postbox_id=postbox.id,
idempotency_key="migrate-e2ee",
expected_revision=postbox.resource_revision,
target_profile="external_e2ee_v1",
target_vault_id=None,
history_mode="migrate_history",
authority_mode="user_consent",
required_quorum=1,
user_consent_refs=("consent:user-1",),
institutional_authorization_refs=(),
reason="Move retained content to client-held keys.",
actor_id="admin-1",
)
self.assertEqual("awaiting_client", transition.state)
self.assertEqual(1, transition.message_count)
completed = self.service.apply_client_protection_transform(
session,
tenant_id="tenant-1",
postbox_id=postbox.id,
transition_id=transition.id,
message_id=delivered.message_id,
expected_revision=transition.resource_revision,
plaintext=None,
ciphertext_ref="files:ciphertext-transition-1",
signed_manifest_ref="files:manifest-transition-1",
wrapped_keys=(
PostboxWrappedKeyRef(
recipient_type="function_postbox",
recipient_id=postbox.id,
key_epoch=postbox.key_epoch,
wrapped_key_ref="trust:wrapped-transition-1",
algorithm="HPKE-v1",
),
),
content_digest=digest,
transformation_evidence_ref="client:evidence-1",
actor_id="admin-1",
)
self.assertEqual("completed", completed.state)
self.assertEqual(1, completed.completed_count)
stored = session.get(PostboxMessage, delivered.message_id)
assert stored is not None
self.assertIsNone(stored.body_text)
self.assertEqual("external_e2ee_v1", stored.encryption_profile)
self.assertEqual("files:ciphertext-transition-1", stored.ciphertext_ref)
self.assertEqual(digest, stored.metadata_["content_digest"])
def test_plaintext_history_migrates_to_managed_envelopes_automatically(
self,
) -> None:
protected = SimpleNamespace(
ciphertext=b"managed-history",
envelope=SimpleNamespace(
envelope_id="transition-envelope-1",
ciphertext_ref="postbox-db://messages/history/body",
algorithm_suite="AES-256-GCM",
wrapped_key_refs=("wrapped-history-1",),
vault_id="vault-1",
key_version=4,
),
)
with Session(self.engine) as session:
postbox = self._create_exact(session)
delivered = self.service.deliver(
session,
PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=postbox.id),
producer_module="campaigns",
producer_resource_type="campaign_recipient",
producer_resource_id="recipient-managed-history",
idempotency_key="managed-history",
subject="Managed history",
body_text="Move this content",
),
)
with patch(
"govoplan_postbox.backend.content_protection.protect_message_body",
return_value=protected,
):
transition = self.service.create_protection_transition(
session,
tenant_id="tenant-1",
postbox_id=postbox.id,
idempotency_key="migrate-managed",
expected_revision=postbox.resource_revision,
target_profile="server_envelope_v1",
target_vault_id="vault-1",
history_mode="migrate_history",
authority_mode="dual_control",
required_quorum=2,
user_consent_refs=("consent:incumbent-1",),
institutional_authorization_refs=("approval:key-holder-1",),
reason="Adopt the managed standard.",
actor_id="admin-1",
)
self.assertEqual("completed", transition.state)
stored = session.get(PostboxMessage, delivered.message_id)
assert stored is not None
self.assertIsNone(stored.body_text)
self.assertEqual(b"managed-history", stored.body_ciphertext)
self.assertEqual("transition-envelope-1", stored.encryption_envelope_id)
self.assertEqual("server_envelope_v1", stored.encryption_profile)
self.assertEqual("vault-1", stored.wrapped_keys[0]["recipient_id"])
def test_new_incumbent_history_policy_filters_lists_counts_and_direct_reads(
self,
) -> None:
boundary = utc_now() - timedelta(days=1)
self.idm.assignments.append(replace(self.assignment, valid_from=boundary))
with Session(self.engine) as session:
postbox = self._create_exact(
session,
protection_policy={"new_incumbent_history": "since_assignment"},
)
old_delivery = self.service.deliver(
session,
PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=postbox.id),
producer_module="campaigns",
producer_resource_type="campaign_recipient",
producer_resource_id="recipient-old",
idempotency_key="history-old",
subject="Before assignment",
body_text="Old content",
),
)
recent_delivery = self.service.deliver(
session,
PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=postbox.id),
producer_module="campaigns",
producer_resource_type="campaign_recipient",
producer_resource_id="recipient-recent",
idempotency_key="history-recent",
subject="After assignment",
body_text="Recent content",
),
)
old = session.get(PostboxMessage, old_delivery.message_id)
recent = session.get(PostboxMessage, recent_delivery.message_id)
assert old is not None and recent is not None
old.delivered_at = boundary - timedelta(hours=1)
recent.delivered_at = boundary + timedelta(hours=1)
session.flush()
messages = self.service.list_messages(
session,
tenant_id="tenant-1",
postbox_ids=(postbox.id,),
actor=self.actor,
)
self.assertEqual([recent.id], [message.id for message in messages])
self.assertEqual(
1,
self.service.count_messages(
session,
tenant_id="tenant-1",
postbox_ids=(postbox.id,),
actor=self.actor,
),
)
self.assertIsNone(
self.service.get_message(
session,
tenant_id="tenant-1",
message_id=old.id,
actor=self.actor,
)
)
self.assertFalse(
self.service.can_read_message(
session,
tenant_id="tenant-1",
message_id=old.id,
actor=self.actor,
)
)
def test_leaving_e2ee_history_requires_user_consent_authority(self) -> None:
with Session(self.engine) as session:
postbox = self._create_exact(
session,
encryption_profile="external_e2ee_v1",
)
with self.assertRaisesRegex(PostboxError, "user consent"):
self.service.create_protection_transition(
session,
tenant_id="tenant-1",
postbox_id=postbox.id,
idempotency_key="leave-e2ee-without-user",
expected_revision=postbox.resource_revision,
target_profile="plaintext_v1",
target_vault_id=None,
history_mode="future_only",
authority_mode="institutional_key_holders",
required_quorum=1,
user_consent_refs=(),
institutional_authorization_refs=("approval:key-holder-1",),
reason="Leave E2EE without holder consent.",
actor_id="admin-1",
)
def test_protection_policy_update_is_revisioned_and_enforces_assurance(
self,
) -> None:
with Session(self.engine) as session:
postbox = self._create_exact(session)
original_revision = postbox.resource_revision
updated = self.service.update_protection_policy(
session,
tenant_id="tenant-1",
postbox_id=postbox.id,
protection_policy={
"new_incumbent_history": "all_retained",
"external_recipient_assurance": "disabled",
},
actor_id="admin-1",
expected_revision=original_revision,
)
self.assertEqual(original_revision + 1, updated.resource_revision)
self.assertEqual(
"disabled",
updated.settings["protection_policy"]["external_recipient_assurance"],
)
self.assertEqual(
"rewrap",
updated.settings["protection_policy"]["ordinary_rotation"],
)
with self.assertRaisesRegex(PostboxError, "does not permit"):
self.service.deliver(
session,
PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=postbox.id),
producer_module="campaigns",
producer_resource_type="campaign_recipient",
producer_resource_id="recipient-external-disabled",
idempotency_key="external-disabled",
subject="External retrieval",
body_text="Content",
external_recipient_tokens=(
PostboxExternalRecipientTokenRef(
token_id="grant-disabled",
state="available",
one_time=True,
assurance_profile="strong_identity",
),
),
),
)
def test_hierarchy_linked_copy_snapshots_path_and_independent_state(
self,
) -> None:
self.idm.assignments.append(self.assignment)
with Session(self.engine) as session:
service, source, _target_template = (
self._create_routing_source(session)
)
service, source, _target_template = self._create_routing_source(session)
result = service.deliver(
session,
PostboxDeliveryRequest(
@@ -1478,10 +1808,7 @@ class PostboxServiceTests(unittest.TestCase):
self.assertNotEqual(result.message_id, route.target_message_id)
self.assertEqual(
["edge-child-parent"],
[
edge["edge_id"]
for edge in route.policy_snapshot["target"]["path"]
],
[edge["edge_id"] for edge in route.policy_snapshot["target"]["path"]],
)
self.assertEqual(
route.id,
@@ -1496,7 +1823,9 @@ class PostboxServiceTests(unittest.TestCase):
)
session.commit()
receipts = session.query(PostboxMessageReceipt).all()
self.assertEqual([route.target_message_id], [item.message_id for item in receipts])
self.assertEqual(
[route.target_message_id], [item.message_id for item in receipts]
)
summary = service.delivery_receipt_summaries(
session,
tenant_id="tenant-1",
@@ -1512,8 +1841,8 @@ class PostboxServiceTests(unittest.TestCase):
self,
) -> None:
with Session(self.engine) as session:
service, source, _target_template = (
self._create_routing_source(session, max_depth=1)
service, source, _target_template = self._create_routing_source(
session, max_depth=1
)
blocked = service.preview_hierarchy_routes(
session,
@@ -1573,9 +1902,7 @@ class PostboxServiceTests(unittest.TestCase):
self.organizations.duplicate_parent_match = True
self.organizations.cycle_detected = True
with Session(self.engine) as session:
service, source, _target_template = (
self._create_routing_source(session)
)
service, source, _target_template = self._create_routing_source(session)
preview = service.preview_hierarchy_routes(
session,
tenant_id="tenant-1",
@@ -1588,10 +1915,7 @@ class PostboxServiceTests(unittest.TestCase):
self.assertIn("hierarchy_cycle_bounded", preview["diagnostics"])
self.assertEqual(
1,
sum(
route["status"] == "duplicate"
for route in preview["routes"]
),
sum(route["status"] == "duplicate" for route in preview["routes"]),
)
with self.assertRaisesRegex(
ValueError,
@@ -1639,11 +1963,7 @@ class PostboxServiceTests(unittest.TestCase):
),
)
session.commit()
routes = (
session.query(PostboxRoute)
.order_by(PostboxRoute.depth)
.all()
)
routes = session.query(PostboxRoute).order_by(PostboxRoute.depth).all()
self.assertEqual(
["accepted_vacant", "pending_vacancy_escalation"],
[route.status for route in routes],
@@ -1687,10 +2007,7 @@ class PostboxServiceTests(unittest.TestCase):
session.commit()
pending = (
session.query(PostboxRoute)
.filter(
PostboxRoute.status
== "pending_vacancy_escalation"
)
.filter(PostboxRoute.status == "pending_vacancy_escalation")
.one()
)
pending.execute_after = utc_now() - timedelta(seconds=1)