diff --git a/README.md b/README.md index 3e53ffe..4ed93a1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/govoplan_postbox/backend/dsar_provider.py b/src/govoplan_postbox/backend/dsar_provider.py new file mode 100644 index 0000000..612621b --- /dev/null +++ b/src/govoplan_postbox/backend/dsar_provider.py @@ -0,0 +1,1105 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarExecutionResultRef, + DsarRecordRef, + DsarSubjectRef, + dsar_capability_name, +) +from govoplan_postbox.backend.db.models import ( + PostboxAccessEvent, + PostboxAttachmentReference, + PostboxDelivery, + PostboxGrouping, + PostboxMessage, + PostboxMessageReceipt, + PostboxParticipant, + PostboxProtectionTransition, + PostboxProtectionTransitionItem, + PostboxRoute, + PostboxTemplate, + PostboxTemplateRevision, +) + + +POSTBOX_DSAR_CAPABILITY = dsar_capability_name("postbox") +_MAX_RECORDS = 5_000 + + +@dataclass(frozen=True, slots=True) +class _SubjectSelectors: + account_id: str | None + identity_id: str | None + membership_id: str | None + assignment_id: str | None + email: str | None + references: Mapping[str, str] + + @property + def actor_ids(self) -> set[str]: + return { + value + for value in (self.account_id, self.identity_id, self.membership_id) + if value + } + + +class PostboxDsarProvider: + provider_id = "postbox" + module_id = "postbox" + + def search_subject( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + ) -> Sequence[DsarRecordRef]: + db = _session(session) + selectors = _subject_selectors(subject) + if selectors is None: + return () + if not ( + selectors.actor_ids + or selectors.assignment_id + or selectors.email + or selectors.references + ): + return () + + records: list[DsarRecordRef] = [] + + def append(record: DsarRecordRef) -> None: + if len(records) >= _MAX_RECORDS: + raise ValueError( + "Postbox DSAR match limit exceeded; narrow the subject selectors." + ) + records.append(record) + + participant_rows = _matching_participants( + db, + tenant_id=tenant_id, + selectors=selectors, + ) + participants_by_message: dict[str, list[PostboxParticipant]] = {} + for participant in participant_rows: + participants_by_message.setdefault(participant.message_id, []).append( + participant + ) + + message_ids = set(participants_by_message) + direct_message_id = selectors.references.get("message") + if direct_message_id: + message_ids.add(direct_message_id) + if selectors.account_id: + message_ids.update( + row.id + for row in _bounded_rows( + db.query(PostboxMessage) + .filter( + PostboxMessage.tenant_id == tenant_id, + PostboxMessage.producer_module == "postbox", + PostboxMessage.producer_resource_type + == "account_authored_message", + PostboxMessage.producer_resource_id == selectors.account_id, + ) + .order_by(PostboxMessage.id) + ) + ) + + messages = _rows_by_ids( + db, + PostboxMessage, + tenant_id=tenant_id, + ids=message_ids, + ) + matched_message_ids = {row.id for row in messages} + for message in messages: + matching_participants = participants_by_message.get(message.id, ()) + authored = bool( + selectors.account_id + and message.producer_module == "postbox" + and message.producer_resource_type == "account_authored_message" + and message.producer_resource_id == selectors.account_id + ) + match_fields = [] + if matching_participants: + match_fields.append("participant") + if authored: + match_fields.append("producer_resource_id") + if message.id == direct_message_id: + match_fields.append("reference") + append( + _record( + "postbox_message", + message.id, + "institutional_message", + _bounded_text(message.subject) or "Postbox message", + { + "match_fields": match_fields, + "postbox_id": message.postbox_id, + "subject": _bounded_text(message.subject), + "body_text": _bounded_text(message.body_text), + "content_state": _content_state(message), + "status": message.status, + "classification": message.classification, + "delivered_at": _iso(message.delivered_at), + "expires_at": _iso(message.expires_at), + "withdrawn_at": _iso(message.withdrawn_at), + "retention_hold_until": _iso(message.retention_hold_until), + }, + observed_at=message.updated_at, + source_path=f"/postbox?message={message.id}", + ) + ) + + direct_participant_id = selectors.references.get("participant") + direct_participants = _rows_by_ids( + db, + PostboxParticipant, + tenant_id=tenant_id, + ids={direct_participant_id} if direct_participant_id else set(), + ) + participant_by_id = { + row.id: row for row in (*participant_rows, *direct_participants) + } + for participant in sorted(participant_by_id.values(), key=lambda row: row.id): + append( + _record( + "postbox_participant", + participant.id, + "institutional_message_participant", + f"{participant.kind.title()} participant", + { + "match_fields": _participant_match_fields( + participant, selectors + ), + "message_id": participant.message_id, + "kind": participant.kind, + "reference_type": participant.reference_type, + "reference_id": participant.reference_id, + "label": _bounded_text(participant.label), + "address": _normalized_email(participant.address), + }, + observed_at=participant.updated_at, + immutable=True, + retention_reason=( + "Message participant metadata is retained with the governed " + "institutional communication record." + ), + source_path=f"/postbox?message={participant.message_id}", + ) + ) + + attachment_ids = { + value for value in (selectors.references.get("attachment"),) if value + } + attachments = _related_or_direct( + db, + PostboxAttachmentReference, + tenant_id=tenant_id, + related_field=PostboxAttachmentReference.message_id, + related_ids=matched_message_ids, + direct_ids=attachment_ids, + ) + for attachment in attachments: + append( + _record( + "postbox_attachment_reference", + attachment.id, + "institutional_message_evidence", + _bounded_text(attachment.name) or "Postbox attachment reference", + { + "message_id": attachment.message_id, + "reference_type": attachment.reference_type, + "reference_id": attachment.reference_id, + "name": _bounded_text(attachment.name), + "media_type": attachment.media_type, + "size_bytes": attachment.size_bytes, + }, + observed_at=attachment.updated_at, + immutable=True, + retention_reason=( + "Attachment references are retained with the institutional " + "message; Files or the referenced provider owns the payload." + ), + source_path=f"/postbox?message={attachment.message_id}", + ) + ) + + deliveries = _related_or_direct( + db, + PostboxDelivery, + tenant_id=tenant_id, + related_field=PostboxDelivery.message_id, + related_ids=matched_message_ids, + direct_ids=_direct_ids(selectors, "delivery"), + ) + delivery_ids = {row.id for row in deliveries} + for delivery in deliveries: + append( + _record( + "postbox_delivery", + delivery.id, + "postbox_delivery_evidence", + "Postbox delivery", + { + "message_id": delivery.message_id, + "postbox_id": delivery.postbox_id, + "producer_module": delivery.producer_module, + "producer_resource_type": delivery.producer_resource_type, + "producer_resource_id": delivery.producer_resource_id, + "status": delivery.status, + "holder_count": delivery.holder_count, + "accepted_at": _iso(delivery.accepted_at), + }, + observed_at=delivery.updated_at, + immutable=True, + retention_reason=( + "Delivery state is immutable institutional transport and " + "responsibility evidence." + ), + source_path=f"/postbox?message={delivery.message_id}", + ) + ) + + routes = _related_or_direct( + db, + PostboxRoute, + tenant_id=tenant_id, + related_field=PostboxRoute.delivery_id, + related_ids=delivery_ids, + direct_ids=_direct_ids(selectors, "route"), + ) + for route in routes: + append( + _record( + "postbox_route", + route.id, + "postbox_routing_evidence", + f"Postbox {route.route_kind} route", + { + "delivery_id": route.delivery_id, + "source_postbox_id": route.source_postbox_id, + "source_message_id": route.source_message_id, + "target_postbox_id": route.target_postbox_id, + "target_message_id": route.target_message_id, + "route_kind": route.route_kind, + "status": route.status, + "depth": route.depth, + "execute_after": _iso(route.execute_after), + "processed_at": _iso(route.processed_at), + }, + observed_at=route.updated_at, + immutable=True, + retention_reason=( + "Routing state is immutable delivery, escalation, and " + "responsibility evidence." + ), + ) + ) + + for receipt in _matching_receipts(db, tenant_id, selectors): + append( + _record( + "postbox_message_receipt", + receipt.id, + "postbox_access_evidence", + "Postbox read and acknowledgement receipt", + { + "match_fields": _matching_fields( + receipt, + selectors, + ("account_id", "identity_id", "assignment_id"), + ), + "message_id": receipt.message_id, + "read_at": _iso(receipt.read_at), + "acknowledged_at": _iso(receipt.acknowledged_at), + }, + observed_at=receipt.updated_at, + immutable=True, + retention_reason=( + "Read and acknowledgement receipts are institutional access " + "and responsibility evidence." + ), + source_path=f"/postbox?message={receipt.message_id}", + ) + ) + + for grouping in _matching_groupings(db, tenant_id, selectors): + append( + _record( + "postbox_grouping", + grouping.id, + "personal_postbox_preference", + grouping.name, + { + "match_fields": ["account_id"], + "name": grouping.name, + "is_default": grouping.is_default, + "resource_revision": grouping.resource_revision, + }, + observed_at=grouping.updated_at, + source_path=f"/postbox?grouping={grouping.id}", + ) + ) + + for event in _matching_access_events(db, tenant_id, selectors): + append( + _record( + "postbox_access_event", + event.id, + "postbox_access_evidence", + f"Postbox access event: {event.action}", + { + "match_fields": _matching_fields( + event, + selectors, + ("account_id", "identity_id", "assignment_id"), + ), + "postbox_id": event.postbox_id, + "message_id": event.message_id, + "action": event.action, + "outcome": event.outcome, + "reason_code": event.reason_code, + "occurred_at": _iso(event.occurred_at), + }, + observed_at=event.updated_at, + immutable=True, + retention_reason=( + "Postbox access events are immutable security and " + "authorization evidence." + ), + ) + ) + + for template in _matching_templates(db, tenant_id, selectors): + append( + _record( + "postbox_template", + template.id, + "postbox_configuration_evidence", + template.name, + { + "match_fields": _actor_match_fields( + template, selectors.actor_ids, ("created_by", "updated_by") + ), + "name": template.name, + "slug": template.slug, + "status": template.status, + "current_revision": template.current_revision, + }, + observed_at=template.updated_at, + immutable=True, + retention_reason=( + "Template authorship is retained as institutional " + "configuration and publication evidence." + ), + source_path="/admin?section=postbox", + ) + ) + + for revision in _matching_template_revisions(db, tenant_id, selectors): + append( + _record( + "postbox_template_revision", + revision.id, + "postbox_configuration_evidence", + f"Postbox template revision {revision.revision}", + { + "match_fields": ["created_by"], + "template_id": revision.template_id, + "revision": revision.revision, + "scope_kind": revision.scope_kind, + "classification": revision.classification, + "published_at": _iso(revision.published_at), + }, + observed_at=revision.updated_at, + immutable=True, + retention_reason=( + "Published template revisions and authorship are immutable " + "configuration evidence." + ), + source_path="/admin?section=postbox", + ) + ) + + transitions = _matching_transitions(db, tenant_id, selectors) + transition_ids = {row.id for row in transitions} + for transition in transitions: + append( + _record( + "postbox_protection_transition", + transition.id, + "postbox_protection_evidence", + "Postbox content-protection transition", + { + "match_fields": _actor_match_fields( + transition, selectors.actor_ids, ("requested_by",) + ), + "postbox_id": transition.postbox_id, + "source_profile": transition.source_profile, + "target_profile": transition.target_profile, + "history_mode": transition.history_mode, + "authority_mode": transition.authority_mode, + "required_quorum": transition.required_quorum, + "state": transition.state, + "message_count": transition.message_count, + "completed_count": transition.completed_count, + "failed_count": transition.failed_count, + "activated_at": _iso(transition.activated_at), + "completed_at": _iso(transition.completed_at), + }, + observed_at=transition.updated_at, + immutable=True, + retention_reason=( + "Protection-transition authority and outcome history is " + "immutable cryptographic-governance evidence." + ), + source_path="/admin?section=postbox", + ) + ) + + for item in _matching_transition_items( + db, tenant_id, selectors, transition_ids + ): + append( + _record( + "postbox_protection_transition_item", + item.id, + "postbox_protection_evidence", + "Postbox message protection-transition outcome", + { + "match_fields": _actor_match_fields( + item, selectors.actor_ids, ("completed_by",) + ), + "transition_id": item.transition_id, + "message_id": item.message_id, + "source_profile": item.source_profile, + "target_profile": item.target_profile, + "state": item.state, + "completed_at": _iso(item.completed_at), + "error_code": item.error_code, + }, + observed_at=item.updated_at, + immutable=True, + retention_reason=( + "Per-message protection outcomes are immutable digest and " + "migration evidence." + ), + source_path="/admin?section=postbox", + ) + ) + + return tuple(records) + + def plan_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + records: Sequence[DsarRecordRef], + ) -> Sequence[DsarErasureActionRef]: + db = _session(session) + selectors = _subject_selectors(subject) + if selectors is None: + raise ValueError("Postbox DSAR subject selectors conflict.") + actions: list[DsarErasureActionRef] = [] + for record in records: + _validate_record(record) + if record.resource_type == "postbox_grouping": + grouping = ( + db.query(PostboxGrouping) + .filter( + PostboxGrouping.tenant_id == tenant_id, + PostboxGrouping.id == record.resource_id, + ) + .one_or_none() + ) + if grouping is None or grouping.account_id != selectors.account_id: + actions.append( + _manual_action( + record, + "The grouping is missing or is no longer owned by the " + "corroborated subject account.", + ) + ) + continue + actions.append( + DsarErasureActionRef( + action_id=( + f"postbox:delete:postbox_grouping:{grouping.id}:" + f"r{grouping.resource_revision}" + ), + provider_id=self.provider_id, + module_id=self.module_id, + kind="delete", + resource_type="postbox_grouping", + resource_id=grouping.id, + title=f"Delete personal grouping {grouping.name}", + rationale=( + "The grouping is a personal projection preference; " + "deleting it does not delete source Postboxes or messages." + ), + executable=True, + irreversible=True, + metadata={ + "account_id": grouping.account_id, + "resource_revision": grouping.resource_revision, + }, + ) + ) + elif record.immutable_evidence: + actions.append( + DsarErasureActionRef( + action_id=( + f"postbox:retain:{record.resource_type}:" + f"{record.resource_id}" + ), + provider_id=self.provider_id, + module_id=self.module_id, + kind="retain", + resource_type=record.resource_type, + resource_id=record.resource_id, + title=f"Retain {record.title}", + rationale=record.retention_reason + or "Postbox institutional evidence must be retained.", + executable=False, + ) + ) + else: + actions.append( + _manual_action( + record, + "Postbox message content and configuration require an " + "authorized retention and third-party privacy review.", + ) + ) + return tuple(actions) + + def execute_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + actions: Sequence[DsarErasureActionRef], + request_id: str, + ) -> Sequence[DsarExecutionResultRef]: + db = _session(session) + selectors = _subject_selectors(subject) + if selectors is None: + raise ValueError("Postbox DSAR subject selectors conflict.") + results: list[DsarExecutionResultRef] = [] + for action in actions: + _validate_action(action) + if not action.executable or action.kind != "delete": + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="blocked", + summary=( + "Postbox institutional records require a separate " + "authorized lifecycle action." + ), + ) + ) + continue + if action.resource_type != "postbox_grouping": + raise ValueError("Unsupported executable Postbox DSAR action.") + grouping = ( + db.query(PostboxGrouping) + .filter( + PostboxGrouping.tenant_id == tenant_id, + PostboxGrouping.id == action.resource_id, + ) + .one_or_none() + ) + if grouping is None: + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="unchanged", + summary="The personal Postbox grouping was already absent.", + evidence={"request_id": request_id}, + ) + ) + continue + expected_owner = str(action.metadata.get("account_id") or "") + expected_revision = action.metadata.get("resource_revision") + if ( + not selectors.account_id + or grouping.account_id != selectors.account_id + or expected_owner != selectors.account_id + ): + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="blocked", + summary=( + "The grouping is not owned by the corroborated subject " + "account." + ), + ) + ) + continue + if expected_revision != grouping.resource_revision: + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="blocked", + summary=( + "The grouping changed after the erasure plan; create a " + "new plan before deleting it." + ), + ) + ) + continue + grouping_id = grouping.id + db.delete(grouping) + db.flush() + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="executed", + summary=( + "Deleted the personal Postbox grouping without changing " + "source Postboxes or messages." + ), + evidence={ + "request_id": request_id, + "grouping_id": grouping_id, + }, + ) + ) + return tuple(results) + + +def _matching_participants( + session: Session, + *, + tenant_id: str, + selectors: _SubjectSelectors, +) -> list[PostboxParticipant]: + conditions = [] + if selectors.account_id: + conditions.append( + (func.lower(PostboxParticipant.reference_type) == "account") + & (PostboxParticipant.reference_id == selectors.account_id) + ) + if selectors.identity_id: + conditions.append( + (func.lower(PostboxParticipant.reference_type) == "identity") + & (PostboxParticipant.reference_id == selectors.identity_id) + ) + if selectors.membership_id: + conditions.append( + func.lower(PostboxParticipant.reference_type).in_(("membership", "user")) + & (PostboxParticipant.reference_id == selectors.membership_id) + ) + if selectors.email: + conditions.append(func.lower(PostboxParticipant.address) == selectors.email) + participant_id = selectors.references.get("participant") + if participant_id: + conditions.append(PostboxParticipant.id == participant_id) + if not conditions: + return [] + return _bounded_rows( + session.query(PostboxParticipant) + .filter(PostboxParticipant.tenant_id == tenant_id, or_(*conditions)) + .order_by(PostboxParticipant.id) + ) + + +def _matching_receipts( + session: Session, tenant_id: str, selectors: _SubjectSelectors +) -> list[PostboxMessageReceipt]: + conditions = [] + if selectors.account_id: + conditions.append(PostboxMessageReceipt.account_id == selectors.account_id) + if selectors.identity_id: + conditions.append(PostboxMessageReceipt.identity_id == selectors.identity_id) + if selectors.assignment_id: + conditions.append( + PostboxMessageReceipt.assignment_id == selectors.assignment_id + ) + if receipt_id := selectors.references.get("receipt"): + conditions.append(PostboxMessageReceipt.id == receipt_id) + return _query_conditions( + session, PostboxMessageReceipt, tenant_id=tenant_id, conditions=conditions + ) + + +def _matching_groupings( + session: Session, tenant_id: str, selectors: _SubjectSelectors +) -> list[PostboxGrouping]: + conditions = [] + if selectors.account_id: + conditions.append(PostboxGrouping.account_id == selectors.account_id) + if grouping_id := selectors.references.get("grouping"): + conditions.append(PostboxGrouping.id == grouping_id) + return _query_conditions( + session, PostboxGrouping, tenant_id=tenant_id, conditions=conditions + ) + + +def _matching_access_events( + session: Session, tenant_id: str, selectors: _SubjectSelectors +) -> list[PostboxAccessEvent]: + conditions = [] + if selectors.account_id: + conditions.append(PostboxAccessEvent.account_id == selectors.account_id) + if selectors.identity_id: + conditions.append(PostboxAccessEvent.identity_id == selectors.identity_id) + if selectors.assignment_id: + conditions.append(PostboxAccessEvent.assignment_id == selectors.assignment_id) + if event_id := selectors.references.get("access_event"): + conditions.append(PostboxAccessEvent.id == event_id) + return _query_conditions( + session, PostboxAccessEvent, tenant_id=tenant_id, conditions=conditions + ) + + +def _matching_templates( + session: Session, tenant_id: str, selectors: _SubjectSelectors +) -> list[PostboxTemplate]: + conditions = [] + if selectors.actor_ids: + conditions.extend( + ( + PostboxTemplate.created_by.in_(selectors.actor_ids), + PostboxTemplate.updated_by.in_(selectors.actor_ids), + ) + ) + if template_id := selectors.references.get("template"): + conditions.append(PostboxTemplate.id == template_id) + return _query_conditions( + session, PostboxTemplate, tenant_id=tenant_id, conditions=conditions + ) + + +def _matching_template_revisions( + session: Session, tenant_id: str, selectors: _SubjectSelectors +) -> list[PostboxTemplateRevision]: + conditions = [] + if selectors.actor_ids: + conditions.append(PostboxTemplateRevision.created_by.in_(selectors.actor_ids)) + if revision_id := selectors.references.get("template_revision"): + conditions.append(PostboxTemplateRevision.id == revision_id) + return _query_conditions( + session, PostboxTemplateRevision, tenant_id=tenant_id, conditions=conditions + ) + + +def _matching_transitions( + session: Session, tenant_id: str, selectors: _SubjectSelectors +) -> list[PostboxProtectionTransition]: + conditions = [] + if selectors.actor_ids: + conditions.append( + PostboxProtectionTransition.requested_by.in_(selectors.actor_ids) + ) + if transition_id := selectors.references.get("protection_transition"): + conditions.append(PostboxProtectionTransition.id == transition_id) + return _query_conditions( + session, + PostboxProtectionTransition, + tenant_id=tenant_id, + conditions=conditions, + ) + + +def _matching_transition_items( + session: Session, + tenant_id: str, + selectors: _SubjectSelectors, + transition_ids: set[str], +) -> list[PostboxProtectionTransitionItem]: + conditions = [] + if transition_ids: + conditions.append( + PostboxProtectionTransitionItem.transition_id.in_(transition_ids) + ) + if selectors.actor_ids: + conditions.append( + PostboxProtectionTransitionItem.completed_by.in_(selectors.actor_ids) + ) + if item_id := selectors.references.get("protection_transition_item"): + conditions.append(PostboxProtectionTransitionItem.id == item_id) + return _query_conditions( + session, + PostboxProtectionTransitionItem, + tenant_id=tenant_id, + conditions=conditions, + ) + + +def _related_or_direct( + session: Session, + model: type, + *, + tenant_id: str, + related_field: object, + related_ids: set[str], + direct_ids: set[str], +) -> list[object]: + conditions = [] + if related_ids: + conditions.append(related_field.in_(related_ids)) # type: ignore[attr-defined] + if direct_ids: + conditions.append(model.id.in_(direct_ids)) + return _query_conditions(session, model, tenant_id=tenant_id, conditions=conditions) + + +def _rows_by_ids( + session: Session, + model: type, + *, + tenant_id: str, + ids: set[str], +) -> list[object]: + if not ids: + return [] + return _bounded_rows( + session.query(model) + .filter(model.tenant_id == tenant_id, model.id.in_(ids)) + .order_by(model.id) + ) + + +def _query_conditions( + session: Session, + model: type, + *, + tenant_id: str, + conditions: Sequence[object], +) -> list[object]: + if not conditions: + return [] + return _bounded_rows( + session.query(model) + .filter(model.tenant_id == tenant_id, or_(*conditions)) + .order_by(model.id) + ) + + +def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None: + groups = { + "account_id": ( + subject.account_id, + subject.external_references.get("postbox.account"), + subject.external_references.get("access.account"), + ), + "identity_id": ( + subject.identity_id, + subject.external_references.get("postbox.identity"), + subject.external_references.get("identity.id"), + ), + "membership_id": ( + subject.membership_id, + subject.external_references.get("postbox.membership"), + subject.external_references.get("access.membership"), + ), + "assignment_id": (subject.external_references.get("postbox.assignment"),), + "email": ( + subject.email, + subject.external_references.get("postbox.email"), + ), + } + normalized: dict[str, str | None] = {} + for key, values in groups.items(): + distinct = { + value + for item in values + if ( + value := ( + _normalized_email(item) if key == "email" else _normalized_id(item) + ) + ) + } + if len(distinct) > 1: + return None + normalized[key] = next(iter(distinct), None) + + aliases = { + "postbox.message": "message", + "postbox.participant": "participant", + "postbox.attachment": "attachment", + "postbox.delivery": "delivery", + "postbox.route": "route", + "postbox.receipt": "receipt", + "postbox.grouping": "grouping", + "postbox.access_event": "access_event", + "postbox.template": "template", + "postbox.template_revision": "template_revision", + "postbox.protection_transition": "protection_transition", + "postbox.protection_transition_item": "protection_transition_item", + } + references = { + target: value + for source, target in aliases.items() + if (value := _normalized_id(subject.external_references.get(source))) + } + return _SubjectSelectors(references=references, **normalized) + + +def _participant_match_fields( + row: PostboxParticipant, selectors: _SubjectSelectors +) -> list[str]: + fields = [] + reference_type = row.reference_type.strip().casefold() + if reference_type == "account" and row.reference_id == selectors.account_id: + fields.append("reference_id") + if reference_type == "identity" and row.reference_id == selectors.identity_id: + fields.append("reference_id") + if ( + reference_type in {"membership", "user"} + and row.reference_id == selectors.membership_id + ): + fields.append("reference_id") + if selectors.email and _normalized_email(row.address) == selectors.email: + fields.append("address") + if row.id == selectors.references.get("participant"): + fields.append("reference") + return fields + + +def _matching_fields( + row: object, + selectors: _SubjectSelectors, + fields: Sequence[str], +) -> list[str]: + expected = { + "account_id": selectors.account_id, + "identity_id": selectors.identity_id, + "assignment_id": selectors.assignment_id, + } + return [ + field + for field in fields + if expected.get(field) and getattr(row, field, None) == expected[field] + ] + + +def _actor_match_fields( + row: object, actor_ids: set[str], fields: Sequence[str] +) -> list[str]: + return [field for field in fields if getattr(row, field, None) in actor_ids] + + +def _direct_ids(selectors: _SubjectSelectors, kind: str) -> set[str]: + return {value for value in (selectors.references.get(kind),) if value} + + +def _content_state(message: PostboxMessage) -> str: + if message.body_text is not None: + return "plaintext_available" + if message.encryption_envelope_id or message.body_ciphertext is not None: + return "institution_managed_envelope" + if message.ciphertext_ref or message.signed_manifest_ref: + return "external_e2ee" + return "no_content" + + +def _manual_action(record: DsarRecordRef, rationale: str) -> DsarErasureActionRef: + return DsarErasureActionRef( + action_id=f"postbox:review:{record.resource_type}:{record.resource_id}", + provider_id="postbox", + module_id="postbox", + kind="manual_review", + resource_type=record.resource_type, + resource_id=record.resource_id, + title=f"Review {record.title}", + rationale=rationale, + executable=False, + ) + + +def _validate_record(record: DsarRecordRef) -> None: + if record.provider_id != "postbox" or record.module_id != "postbox": + raise ValueError("Postbox DSAR received a foreign provider record.") + + +def _validate_action(action: DsarErasureActionRef) -> None: + if action.provider_id != "postbox" or action.module_id != "postbox": + raise ValueError("Postbox DSAR received a foreign provider action.") + + +def _record( + resource_type: str, + resource_id: str, + category: str, + title: str, + data: Mapping[str, object], + *, + observed_at: datetime | None = None, + immutable: bool = False, + retention_reason: str | None = None, + source_path: str | None = None, +) -> DsarRecordRef: + return DsarRecordRef( + provider_id="postbox", + module_id="postbox", + resource_type=resource_type, + resource_id=resource_id, + category=category, + title=title, + data=data, + observed_at=observed_at, + immutable_evidence=immutable, + retention_reason=retention_reason, + source_path=source_path, + ) + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Postbox DSAR provider requires a SQLAlchemy session.") + return value + + +def _bounded_rows(query: object) -> list[object]: + rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined] + if len(rows) > _MAX_RECORDS: + raise ValueError( + "Postbox DSAR match limit exceeded; narrow the subject selectors." + ) + return rows + + +def _bounded_text(value: str | None) -> str | None: + return value[:2_000] if value else None + + +def _normalized_email(value: object) -> str | None: + if not isinstance(value, str): + return None + value = value.strip().casefold() + return value or None + + +def _normalized_id(value: object) -> str | None: + if value is None: + return None + value = str(value).strip() + return value or None + + +def _iso(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.isoformat() + + +__all__ = ["POSTBOX_DSAR_CAPABILITY", "PostboxDsarProvider"] diff --git a/src/govoplan_postbox/backend/manifest.py b/src/govoplan_postbox/backend/manifest.py index 30c121c..8d47afa 100644 --- a/src/govoplan_postbox/backend/manifest.py +++ b/src/govoplan_postbox/backend/manifest.py @@ -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", diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py new file mode 100644 index 0000000..7b9c359 --- /dev/null +++ b/tests/test_dsar_provider.py @@ -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() diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 28212c7..b3a222e 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -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,