606 lines
21 KiB
Python
606 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from govoplan_access.backend.db.models import Account, Group, User
|
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
|
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_core.privacy.dsar_workflow import (
|
|
DataSubjectRequest,
|
|
create_data_subject_request,
|
|
plan_data_subject_erasure,
|
|
search_data_subject_request,
|
|
)
|
|
from govoplan_postbox.backend.db.models import (
|
|
Postbox,
|
|
PostboxAccessEvent,
|
|
PostboxAddress,
|
|
PostboxAttachmentReference,
|
|
PostboxDelivery,
|
|
PostboxGrouping,
|
|
PostboxGroupingSource,
|
|
PostboxMessage,
|
|
PostboxMessageReceipt,
|
|
PostboxParticipant,
|
|
PostboxProtectionTransition,
|
|
PostboxProtectionTransitionItem,
|
|
PostboxRoute,
|
|
PostboxTemplate,
|
|
PostboxTemplateRevision,
|
|
)
|
|
from govoplan_postbox.backend.dsar_provider import (
|
|
POSTBOX_DSAR_CAPABILITY,
|
|
PostboxDsarProvider,
|
|
)
|
|
from govoplan_postbox.backend.manifest import manifest
|
|
|
|
|
|
class _Registry:
|
|
def __init__(self, provider: object, *, active: bool = True):
|
|
self.provider = provider
|
|
self.active = active
|
|
|
|
def capability_names(self):
|
|
return (POSTBOX_DSAR_CAPABILITY,)
|
|
|
|
def capability_owner(self, name):
|
|
assert name == POSTBOX_DSAR_CAPABILITY
|
|
return "postbox"
|
|
|
|
def tenant_entitlement_resolver(self):
|
|
active = self.active
|
|
|
|
class Resolver:
|
|
@staticmethod
|
|
def resolve(session, tenant_id):
|
|
del session, tenant_id
|
|
return type(
|
|
"State",
|
|
(),
|
|
{"effective_modules": ("postbox",) if active else ()},
|
|
)()
|
|
|
|
return Resolver()
|
|
|
|
def require_tenant_capability(self, name, session, **kwargs):
|
|
del session, kwargs
|
|
assert name == POSTBOX_DSAR_CAPABILITY
|
|
return self.provider
|
|
|
|
|
|
class PostboxDsarProviderTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.engine = create_engine("sqlite:///:memory:", future=True)
|
|
Base.metadata.create_all(
|
|
self.engine,
|
|
tables=[
|
|
Account.__table__,
|
|
User.__table__,
|
|
Group.__table__,
|
|
ChangeSequenceEntry.__table__,
|
|
DataSubjectRequest.__table__,
|
|
PostboxTemplate.__table__,
|
|
PostboxTemplateRevision.__table__,
|
|
PostboxAddress.__table__,
|
|
Postbox.__table__,
|
|
PostboxMessage.__table__,
|
|
PostboxParticipant.__table__,
|
|
PostboxAttachmentReference.__table__,
|
|
PostboxDelivery.__table__,
|
|
PostboxRoute.__table__,
|
|
PostboxMessageReceipt.__table__,
|
|
PostboxGrouping.__table__,
|
|
PostboxGroupingSource.__table__,
|
|
PostboxAccessEvent.__table__,
|
|
PostboxProtectionTransition.__table__,
|
|
PostboxProtectionTransitionItem.__table__,
|
|
],
|
|
)
|
|
self.session = sessionmaker(bind=self.engine, future=True)()
|
|
now = datetime.now(timezone.utc)
|
|
account = Account(
|
|
id="account-subject",
|
|
email="subject@example.test",
|
|
normalized_email="subject@example.test",
|
|
display_name="Subject",
|
|
)
|
|
user = User(
|
|
id="membership-subject",
|
|
tenant_id="tenant-1",
|
|
account_id=account.id,
|
|
email="subject@example.test",
|
|
display_name="Subject",
|
|
)
|
|
template = PostboxTemplate(
|
|
id="template-subject",
|
|
tenant_id="tenant-1",
|
|
slug="subject-template",
|
|
name="Subject configured template",
|
|
status="published",
|
|
created_by=account.id,
|
|
updated_by=account.id,
|
|
)
|
|
revision = PostboxTemplateRevision(
|
|
id="template-revision-subject",
|
|
tenant_id="tenant-1",
|
|
template_id=template.id,
|
|
revision=1,
|
|
created_by=account.id,
|
|
published_at=now,
|
|
)
|
|
address = PostboxAddress(
|
|
id="address-1",
|
|
tenant_id="tenant-1",
|
|
address_key="office",
|
|
address="office.postbox",
|
|
status="active",
|
|
)
|
|
postbox = Postbox(
|
|
id="postbox-1",
|
|
tenant_id="tenant-1",
|
|
address_id=address.id,
|
|
name="Office Postbox",
|
|
classification="confidential",
|
|
)
|
|
message = PostboxMessage(
|
|
id="message-subject",
|
|
tenant_id="tenant-1",
|
|
postbox_id=postbox.id,
|
|
subject="Subject request context",
|
|
body_text="Bounded plaintext concerning the subject",
|
|
status="delivered",
|
|
classification="personal",
|
|
producer_module="postbox",
|
|
producer_resource_type="account_authored_message",
|
|
producer_resource_id=account.id,
|
|
authoring_key="authoring-secret-do-not-export",
|
|
delivered_at=now,
|
|
metadata_={"secret": "message-metadata-do-not-export"},
|
|
)
|
|
matching_participant = PostboxParticipant(
|
|
id="participant-subject",
|
|
tenant_id="tenant-1",
|
|
message_id=message.id,
|
|
kind="recipient",
|
|
reference_type="account",
|
|
reference_id=account.id,
|
|
label="Subject Person",
|
|
address="Subject@Example.Test",
|
|
position=1,
|
|
metadata_={"secret": "participant-metadata-do-not-export"},
|
|
)
|
|
unrelated_participant = PostboxParticipant(
|
|
id="participant-other",
|
|
tenant_id="tenant-1",
|
|
message_id=message.id,
|
|
kind="recipient",
|
|
reference_type="account",
|
|
reference_id="account-other",
|
|
label="Unrelated Person Do Not Export",
|
|
address="other@example.test",
|
|
position=2,
|
|
)
|
|
attachment = PostboxAttachmentReference(
|
|
id="attachment-subject",
|
|
tenant_id="tenant-1",
|
|
message_id=message.id,
|
|
reference_type="file_version",
|
|
reference_id="file-version-1",
|
|
name="subject-evidence.pdf",
|
|
digest="digest-do-not-export",
|
|
metadata_={"secret": "attachment-metadata-do-not-export"},
|
|
)
|
|
encrypted_message = PostboxMessage(
|
|
id="message-encrypted",
|
|
tenant_id="tenant-1",
|
|
postbox_id=postbox.id,
|
|
subject="Encrypted subject context",
|
|
body_ciphertext=b"ciphertext-do-not-export",
|
|
status="delivered",
|
|
classification="personal",
|
|
encryption_profile="server_envelope_v1",
|
|
encryption_envelope_id="envelope-do-not-export",
|
|
encryption_resource_id="resource-do-not-export",
|
|
wrapped_keys=[{"wrapped_key_ref": "wrapped-key-do-not-export"}],
|
|
external_recipient_tokens=[{"token_id": "external-token-do-not-export"}],
|
|
delivered_at=now,
|
|
)
|
|
encrypted_participant = PostboxParticipant(
|
|
id="participant-encrypted-subject",
|
|
tenant_id="tenant-1",
|
|
message_id=encrypted_message.id,
|
|
kind="recipient",
|
|
reference_type="identity",
|
|
reference_id="identity-subject",
|
|
position=1,
|
|
)
|
|
unrelated_message = PostboxMessage(
|
|
id="message-other",
|
|
tenant_id="tenant-1",
|
|
postbox_id=postbox.id,
|
|
subject="Unrelated message do not export",
|
|
body_text="Unrelated body do not export",
|
|
status="delivered",
|
|
delivered_at=now,
|
|
)
|
|
tenant_two_address = PostboxAddress(
|
|
id="address-tenant-2",
|
|
tenant_id="tenant-2",
|
|
address_key="other",
|
|
address="other.postbox",
|
|
)
|
|
tenant_two_postbox = Postbox(
|
|
id="postbox-tenant-2",
|
|
tenant_id="tenant-2",
|
|
address_id=tenant_two_address.id,
|
|
name="Tenant two Postbox",
|
|
)
|
|
tenant_two_message = PostboxMessage(
|
|
id="message-tenant-2",
|
|
tenant_id="tenant-2",
|
|
postbox_id=tenant_two_postbox.id,
|
|
subject="Tenant two message do not export",
|
|
body_text="Tenant two body do not export",
|
|
delivered_at=now,
|
|
)
|
|
tenant_two_participant = PostboxParticipant(
|
|
id="participant-tenant-2",
|
|
tenant_id="tenant-2",
|
|
message_id=tenant_two_message.id,
|
|
kind="recipient",
|
|
reference_type="account",
|
|
reference_id=account.id,
|
|
)
|
|
delivery = PostboxDelivery(
|
|
id="delivery-subject",
|
|
tenant_id="tenant-1",
|
|
postbox_id=postbox.id,
|
|
message_id=message.id,
|
|
producer_module="postbox",
|
|
producer_resource_type="account_authored_message",
|
|
producer_resource_id=account.id,
|
|
idempotency_key="delivery-idempotency-do-not-export",
|
|
status="accepted",
|
|
holder_count=1,
|
|
target_snapshot={"secret": "target-snapshot-do-not-export"},
|
|
accepted_at=now,
|
|
metadata_={"secret": "delivery-metadata-do-not-export"},
|
|
)
|
|
route = PostboxRoute(
|
|
id="route-subject",
|
|
tenant_id="tenant-1",
|
|
delivery_id=delivery.id,
|
|
source_postbox_id=postbox.id,
|
|
source_message_id=message.id,
|
|
target_postbox_id=postbox.id,
|
|
target_message_id=message.id,
|
|
route_kind="linked_copy",
|
|
status="completed",
|
|
depth=1,
|
|
processed_at=now,
|
|
policy_snapshot={"secret": "route-policy-do-not-export"},
|
|
)
|
|
receipt = PostboxMessageReceipt(
|
|
id="receipt-subject",
|
|
tenant_id="tenant-1",
|
|
message_id=message.id,
|
|
account_id=account.id,
|
|
identity_id="identity-subject",
|
|
assignment_id="assignment-subject",
|
|
read_at=now,
|
|
acknowledged_at=now,
|
|
metadata_={"secret": "receipt-metadata-do-not-export"},
|
|
)
|
|
grouping = PostboxGrouping(
|
|
id="grouping-subject",
|
|
tenant_id="tenant-1",
|
|
account_id=account.id,
|
|
name="My work",
|
|
is_default=True,
|
|
settings={"secret": "grouping-settings-do-not-export"},
|
|
)
|
|
grouping.sources.append(
|
|
PostboxGroupingSource(
|
|
id="grouping-source-subject",
|
|
tenant_id="tenant-1",
|
|
postbox_id=postbox.id,
|
|
position=0,
|
|
)
|
|
)
|
|
access_event = PostboxAccessEvent(
|
|
id="access-event-subject",
|
|
tenant_id="tenant-1",
|
|
postbox_id=postbox.id,
|
|
message_id=message.id,
|
|
account_id=account.id,
|
|
identity_id="identity-subject",
|
|
assignment_id="assignment-subject",
|
|
action="read_message",
|
|
outcome="allowed",
|
|
reason_code="assigned",
|
|
occurred_at=now,
|
|
details={"secret": "access-details-do-not-export"},
|
|
)
|
|
transition = PostboxProtectionTransition(
|
|
id="transition-subject",
|
|
tenant_id="tenant-1",
|
|
postbox_id=postbox.id,
|
|
idempotency_key="transition-idempotency-do-not-export",
|
|
source_profile="plaintext_v1",
|
|
target_profile="server_envelope_v1",
|
|
history_mode="migrate",
|
|
authority_mode="institutional",
|
|
required_quorum=1,
|
|
evidence_refs=["evidence-ref-do-not-export"],
|
|
reason="private transition reason do not export",
|
|
state="completed",
|
|
message_count=1,
|
|
completed_count=1,
|
|
requested_by=account.id,
|
|
activated_at=now,
|
|
completed_at=now,
|
|
configuration_snapshot={"secret": "transition-config-do-not-export"},
|
|
)
|
|
transition_item = PostboxProtectionTransitionItem(
|
|
id="transition-item-subject",
|
|
tenant_id="tenant-1",
|
|
transition_id=transition.id,
|
|
message_id=message.id,
|
|
source_profile="plaintext_v1",
|
|
target_profile="server_envelope_v1",
|
|
state="completed",
|
|
source_digest="source-digest-do-not-export",
|
|
target_digest="target-digest-do-not-export",
|
|
completed_by=account.id,
|
|
completed_at=now,
|
|
evidence={"secret": "transition-item-evidence-do-not-export"},
|
|
)
|
|
self.session.add_all(
|
|
[
|
|
account,
|
|
user,
|
|
template,
|
|
revision,
|
|
address,
|
|
postbox,
|
|
message,
|
|
matching_participant,
|
|
unrelated_participant,
|
|
attachment,
|
|
encrypted_message,
|
|
encrypted_participant,
|
|
unrelated_message,
|
|
tenant_two_address,
|
|
tenant_two_postbox,
|
|
tenant_two_message,
|
|
tenant_two_participant,
|
|
delivery,
|
|
route,
|
|
receipt,
|
|
grouping,
|
|
access_event,
|
|
transition,
|
|
transition_item,
|
|
]
|
|
)
|
|
self.session.commit()
|
|
self.provider = PostboxDsarProvider()
|
|
self.subject = DsarSubjectRef(
|
|
account_id=account.id,
|
|
identity_id="identity-subject",
|
|
membership_id=user.id,
|
|
email="subject@example.test",
|
|
external_references={"postbox.assignment": "assignment-subject"},
|
|
)
|
|
|
|
def tearDown(self) -> None:
|
|
self.session.close()
|
|
self.engine.dispose()
|
|
|
|
def test_manifest_and_minimized_tenant_scoped_search(self) -> None:
|
|
self.assertIn(
|
|
POSTBOX_DSAR_CAPABILITY,
|
|
{item.name for item in manifest.provides_interfaces},
|
|
)
|
|
self.assertIsInstance(
|
|
manifest.capability_factories[POSTBOX_DSAR_CAPABILITY](None),
|
|
DsarProvider,
|
|
)
|
|
records = self.provider.search_subject(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
subject=self.subject,
|
|
)
|
|
self.assertTrue(
|
|
{
|
|
"postbox_message",
|
|
"postbox_participant",
|
|
"postbox_attachment_reference",
|
|
"postbox_delivery",
|
|
"postbox_route",
|
|
"postbox_message_receipt",
|
|
"postbox_grouping",
|
|
"postbox_access_event",
|
|
"postbox_template",
|
|
"postbox_template_revision",
|
|
"postbox_protection_transition",
|
|
"postbox_protection_transition_item",
|
|
}.issubset({record.resource_type for record in records})
|
|
)
|
|
encrypted = next(
|
|
record
|
|
for record in records
|
|
if record.resource_type == "postbox_message"
|
|
and record.resource_id == "message-encrypted"
|
|
)
|
|
self.assertEqual(
|
|
"institution_managed_envelope", encrypted.data["content_state"]
|
|
)
|
|
serialized = repr([record.to_dict() for record in records])
|
|
for hidden in (
|
|
"Unrelated Person Do Not Export",
|
|
"other@example.test",
|
|
"participant-other",
|
|
"Unrelated message do not export",
|
|
"Unrelated body do not export",
|
|
"message-tenant-2",
|
|
"Tenant two message do not export",
|
|
"ciphertext-do-not-export",
|
|
"envelope-do-not-export",
|
|
"resource-do-not-export",
|
|
"wrapped-key-do-not-export",
|
|
"external-token-do-not-export",
|
|
"authoring-secret-do-not-export",
|
|
"message-metadata-do-not-export",
|
|
"participant-metadata-do-not-export",
|
|
"digest-do-not-export",
|
|
"attachment-metadata-do-not-export",
|
|
"delivery-idempotency-do-not-export",
|
|
"target-snapshot-do-not-export",
|
|
"delivery-metadata-do-not-export",
|
|
"route-policy-do-not-export",
|
|
"receipt-metadata-do-not-export",
|
|
"grouping-settings-do-not-export",
|
|
"access-details-do-not-export",
|
|
"transition-idempotency-do-not-export",
|
|
"evidence-ref-do-not-export",
|
|
"private transition reason do not export",
|
|
"transition-config-do-not-export",
|
|
"source-digest-do-not-export",
|
|
"target-digest-do-not-export",
|
|
"transition-item-evidence-do-not-export",
|
|
):
|
|
self.assertNotIn(hidden, serialized)
|
|
|
|
def test_conflicting_selectors_fail_closed(self) -> None:
|
|
records = self.provider.search_subject(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
subject=DsarSubjectRef(
|
|
account_id="account-subject",
|
|
external_references={"postbox.account": "account-other"},
|
|
),
|
|
)
|
|
self.assertEqual((), records)
|
|
|
|
def test_grouping_erasure_is_revalidated_and_idempotent(self) -> None:
|
|
records = self.provider.search_subject(
|
|
self.session, tenant_id="tenant-1", subject=self.subject
|
|
)
|
|
actions = self.provider.plan_erasure(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
subject=self.subject,
|
|
records=records,
|
|
)
|
|
self.assertTrue(
|
|
{"retain", "manual_review", "delete"}.issubset(
|
|
{action.kind for action in actions}
|
|
)
|
|
)
|
|
delete = next(action for action in actions if action.kind == "delete")
|
|
grouping = self.session.get(PostboxGrouping, "grouping-subject")
|
|
assert grouping is not None
|
|
grouping.resource_revision += 1
|
|
self.session.commit()
|
|
stale = self.provider.execute_erasure(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
subject=self.subject,
|
|
actions=(delete,),
|
|
request_id="request-1",
|
|
)
|
|
self.assertEqual("blocked", stale[0].status)
|
|
|
|
refreshed_records = self.provider.search_subject(
|
|
self.session, tenant_id="tenant-1", subject=self.subject
|
|
)
|
|
refreshed_actions = self.provider.plan_erasure(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
subject=self.subject,
|
|
records=refreshed_records,
|
|
)
|
|
refreshed_delete = next(
|
|
action for action in refreshed_actions if action.kind == "delete"
|
|
)
|
|
executed = self.provider.execute_erasure(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
subject=self.subject,
|
|
actions=(refreshed_delete,),
|
|
request_id="request-1",
|
|
)
|
|
self.assertEqual("executed", executed[0].status)
|
|
self.assertIsNone(self.session.get(PostboxGrouping, "grouping-subject"))
|
|
self.assertIsNotNone(self.session.get(PostboxMessage, "message-subject"))
|
|
replay = self.provider.execute_erasure(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
subject=self.subject,
|
|
actions=(refreshed_delete,),
|
|
request_id="request-1",
|
|
)
|
|
self.assertEqual("unchanged", replay[0].status)
|
|
|
|
def test_core_workflow_discovers_active_and_skips_disabled_provider(self) -> None:
|
|
request = create_data_subject_request(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
reference="DSAR-POSTBOX-1",
|
|
request_kind="access_and_erasure",
|
|
subject=self.subject,
|
|
purpose="Authorized request",
|
|
legal_basis="GDPR",
|
|
due_at=datetime.now(timezone.utc) + timedelta(days=30),
|
|
requested_by_account_id="privacy-officer",
|
|
)
|
|
self.session.commit()
|
|
search_data_subject_request(
|
|
self.session,
|
|
registry=_Registry(self.provider),
|
|
row=request,
|
|
expected_revision=1,
|
|
)
|
|
self.assertEqual(["postbox"], request.coverage["covered_modules"])
|
|
plan_data_subject_erasure(
|
|
self.session,
|
|
registry=_Registry(self.provider),
|
|
row=request,
|
|
expected_revision=2,
|
|
)
|
|
self.assertTrue(
|
|
any(action["executable"] for action in request.erasure_plan["actions"])
|
|
)
|
|
|
|
disabled = create_data_subject_request(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
reference="DSAR-POSTBOX-OFF",
|
|
request_kind="access",
|
|
subject=self.subject,
|
|
purpose="Coverage",
|
|
legal_basis=None,
|
|
due_at=None,
|
|
requested_by_account_id="privacy-officer",
|
|
)
|
|
search_data_subject_request(
|
|
self.session,
|
|
registry=_Registry(self.provider, active=False),
|
|
row=disabled,
|
|
expected_revision=1,
|
|
)
|
|
self.assertEqual(
|
|
[POSTBOX_DSAR_CAPABILITY],
|
|
disabled.coverage["inactive_provider_capabilities"],
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|