feat(postbox): add governed DSAR coverage
This commit is contained in:
@@ -135,6 +135,23 @@ envelope changes use the Encryption migration ledger. Any transition to or from
|
||||
E2EE waits for client-supplied transforms for historical messages; the module
|
||||
does not claim or silently simulate native browser/device key custody.
|
||||
|
||||
## Data-subject requests
|
||||
|
||||
Postbox contributes a tenant-isolated provider to the Core data-subject request
|
||||
workflow. It finds bounded personal message, participant, receipt, grouping,
|
||||
access, configuration-authorship, and content-protection metadata. It never
|
||||
decrypts or exports ciphertext, envelopes, wrapped keys, external-recipient
|
||||
tokens, opaque metadata, or unrelated participant data. Institutional delivery,
|
||||
routing, acknowledgement, access, template, and protection-transition evidence
|
||||
is retained with an explicit reason and message content remains subject to
|
||||
manual records and third-party privacy review.
|
||||
|
||||
Personal unified-inbox groupings are the one directly executable erasure
|
||||
operation. Execution revalidates tenant, subject ownership, and the grouping
|
||||
revision, then deletes only the personal projection and its source preferences;
|
||||
source Postboxes and messages are unchanged. Files attachments, producer
|
||||
records, identities, and function assignments remain with their owning modules.
|
||||
|
||||
Run focused checks with:
|
||||
|
||||
```bash
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,8 @@ from govoplan_core.core.module_guards import (
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -53,6 +55,7 @@ from govoplan_core.core.tasks import WorkItemProviderRegistration
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_postbox.backend.db import models as postbox_models
|
||||
from govoplan_postbox.backend.dsar_provider import POSTBOX_DSAR_CAPABILITY
|
||||
from govoplan_postbox.backend.search_source import create_postbox_search_source
|
||||
from govoplan_postbox.backend.permissions import (
|
||||
ACKNOWLEDGE_SCOPE,
|
||||
@@ -181,6 +184,13 @@ def _work_items(context: ModuleContext):
|
||||
return PostboxWorkItemProvider(registry=context.registry)
|
||||
|
||||
|
||||
def _postbox_dsar_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_postbox.backend.dsar_provider import PostboxDsarProvider
|
||||
|
||||
return PostboxDsarProvider()
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
return {
|
||||
"postboxes": session.query(postbox_models.Postbox)
|
||||
@@ -260,6 +270,7 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
CAPABILITY_POSTBOX_ROUTING,
|
||||
CAPABILITY_POSTBOX_PORTAL,
|
||||
POSTBOX_DSAR_CAPABILITY,
|
||||
)
|
||||
),
|
||||
requires_interfaces=(
|
||||
@@ -444,8 +455,133 @@ manifest = ModuleManifest(
|
||||
"govoplan_postbox.backend.portal_projection",
|
||||
fromlist=["create_portal_projection"],
|
||||
).create_portal_projection(context),
|
||||
POSTBOX_DSAR_CAPABILITY: _postbox_dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
POSTBOX_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Postbox data-subject request provider",
|
||||
summary=(
|
||||
"Finds bounded personal Postbox communication, preference, access, "
|
||||
"and governance metadata without exposing ciphertext, keys, tokens, "
|
||||
"opaque metadata, or unrelated participants."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
documentation_types=("admin",),
|
||||
audience=("privacy_officer", "postbox_admin", "records_manager"),
|
||||
),
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="postbox.privacy.data-subject-requests",
|
||||
title="Review Postbox data in a data-subject request",
|
||||
summary=(
|
||||
"Collect tenant-scoped personal Postbox data while preserving "
|
||||
"institutional communication, access, and protection evidence."
|
||||
),
|
||||
body=(
|
||||
"Postbox searches corroborated account, identity, membership, email, "
|
||||
"assignment, and namespaced Postbox references. Results include bounded "
|
||||
"message content for privacy review, only matching participant data, "
|
||||
"personal read and acknowledgement receipts, personal unified-inbox "
|
||||
"groupings, attributed access events, and configuration or content-"
|
||||
"protection authorship. Delivery, routing, receipt, access, template, and "
|
||||
"protection-transition records retain explicit institutional evidence "
|
||||
"reasons. Ciphertext, server envelopes, wrapped keys, external-recipient "
|
||||
"tokens, opaque metadata, transition evidence payloads, and unrelated "
|
||||
"participants are never exported by this provider. Personal groupings are "
|
||||
"the only automated erasure action: execution rechecks tenant, owner, and "
|
||||
"revision, then removes only the projection. Messages and other "
|
||||
"institutional records require a separate authorized retention, third-party "
|
||||
"privacy, and records review. Files, producer modules, Identity, and IDM "
|
||||
"remain authoritative for their own data."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=(
|
||||
"privacy_officer",
|
||||
"postbox_admin",
|
||||
"records_manager",
|
||||
"operator",
|
||||
),
|
||||
related_modules=(
|
||||
"access",
|
||||
"audit",
|
||||
"files",
|
||||
"identity",
|
||||
"idm",
|
||||
"records",
|
||||
),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("postbox", "access"),
|
||||
any_scopes=(
|
||||
"access:privacy:read",
|
||||
"access:privacy:manage",
|
||||
"access:privacy:erase",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Data-subject requests",
|
||||
href="/admin?section=tenant-data-subject-requests",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Postbox concept",
|
||||
href="docs/POSTBOX_CONCEPT.md",
|
||||
kind="source",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Postfachdaten in einem Betroffenenersuchen prüfen",
|
||||
"summary": (
|
||||
"Mandantenbezogene personenbezogene Postfachdaten erfassen "
|
||||
"und institutionelle Kommunikations-, Zugriffs- und "
|
||||
"Schutznachweise bewahren."
|
||||
),
|
||||
"body": (
|
||||
"Postbox sucht nach bestätigten Konto-, Identitäts-, "
|
||||
"Mitgliedschafts-, E-Mail-, Zuweisungs- und namensraumgebundenen "
|
||||
"Postbox-Referenzen. Die Ergebnisse enthalten begrenzte "
|
||||
"Nachrichteninhalte zur Datenschutzprüfung, ausschließlich passende "
|
||||
"Beteiligtenangaben, persönliche Lese- und Bestätigungsbelege, "
|
||||
"persönliche Sammelansichten, zugeordnete Zugriffsereignisse sowie "
|
||||
"Urheberschaft an Konfigurationen und Inhaltsschutzwechseln. "
|
||||
"Zustellung, Routing, Empfangsbelege, Zugriff, Vorlagen und "
|
||||
"Schutzwechsel behalten ausdrückliche institutionelle "
|
||||
"Aufbewahrungsgründe. Chiffrate, Server-Umschläge, umhüllte "
|
||||
"Schlüssel, externe Empfänger-Token, undurchsichtige Metadaten, "
|
||||
"Nachweisnutzdaten von Schutzwechseln und Angaben unbeteiligter "
|
||||
"Personen werden niemals exportiert. Nur persönliche "
|
||||
"Sammelansichten können automatisiert gelöscht werden: Die "
|
||||
"Ausführung prüft Mandant, Eigentümer und Revision erneut und "
|
||||
"entfernt weder Quellpostfächer noch Nachrichten. Nachrichten und "
|
||||
"andere institutionelle Datensätze benötigen eine gesondert "
|
||||
"autorisierte Aufbewahrungs-, Drittschutz- und Aktenprüfung. Files, "
|
||||
"erzeugende Module, Identity und IDM bleiben für ihre Daten "
|
||||
"zuständig."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=tenant-data-subject-requests",
|
||||
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||
"steps": [
|
||||
"Run the Postbox provider and review message, participant, receipt, grouping, access, and governance dispositions.",
|
||||
"Retain institutional delivery, routing, acknowledgement, access, and protection evidence with its reason.",
|
||||
"Review plaintext content for third-party data and applicable records or hold policy before a separate lifecycle action.",
|
||||
"Execute personal-grouping deletion only from a fresh plan; verify source Postboxes and messages remain unchanged.",
|
||||
],
|
||||
"limitations": [
|
||||
"Encrypted content is reported by protection state but is not decrypted or exported by the provider.",
|
||||
"Attachment payloads, producer records, identity records, and assignment records remain with their owning modules.",
|
||||
],
|
||||
},
|
||||
order=31,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="postbox.files.evidence-references",
|
||||
title="Open permitted Files evidence from Postbox",
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
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()
|
||||
@@ -12,6 +12,7 @@ from govoplan_core.core.postbox import (
|
||||
CAPABILITY_POSTBOX_ROUTING,
|
||||
)
|
||||
from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER
|
||||
from govoplan_postbox.backend.dsar_provider import POSTBOX_DSAR_CAPABILITY
|
||||
from govoplan_postbox.backend.manifest import get_manifest
|
||||
|
||||
|
||||
@@ -33,11 +34,14 @@ class PostboxManifestTests(unittest.TestCase):
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
CAPABILITY_POSTBOX_ROUTING,
|
||||
CAPABILITY_POSTBOX_PORTAL,
|
||||
POSTBOX_DSAR_CAPABILITY,
|
||||
},
|
||||
set(manifest.capability_factories),
|
||||
)
|
||||
self.assertEqual("@govoplan/postbox-webui", manifest.frontend.package_name)
|
||||
self.assertEqual(["/postbox"], [route.path for route in manifest.frontend.routes])
|
||||
self.assertEqual(
|
||||
["/postbox"], [route.path for route in manifest.frontend.routes]
|
||||
)
|
||||
self.assertIn(
|
||||
"idm.function_assignments",
|
||||
manifest.required_capabilities,
|
||||
|
||||
Reference in New Issue
Block a user