feat: add governed Mail DSAR coverage
This commit is contained in:
@@ -19,6 +19,15 @@ This repository owns:
|
|||||||
|
|
||||||
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
|
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
|
||||||
|
|
||||||
|
Mail publishes `privacy.dsar.mail` for Core's governed data-subject-request
|
||||||
|
workflow. It isolates matching mailbox header parties and returns bounded index,
|
||||||
|
personal-profile, delivery, reconciliation, and bounce metadata. SMTP/IMAP
|
||||||
|
configuration and credentials, encrypted messages and envelopes, folder/UID
|
||||||
|
locators, worker and idempotency state, diagnostics, and opaque evidence are
|
||||||
|
excluded. Delivery and bounce outcomes remain retained evidence; mailbox and
|
||||||
|
profile changes require coordinated Mail and external-provider review, so the
|
||||||
|
provider does not perform direct erasure.
|
||||||
|
|
||||||
## Profile and credential ownership
|
## Profile and credential ownership
|
||||||
|
|
||||||
Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP
|
Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email.utils import getaddresses
|
||||||
|
|
||||||
|
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_mail.backend.db.models import (
|
||||||
|
MailBounceObservation,
|
||||||
|
MailDeliveryAttempt,
|
||||||
|
MailDeliveryCommand,
|
||||||
|
MailDeliveryReconciliation,
|
||||||
|
MailMailboxMessageIndex,
|
||||||
|
MailServerProfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MAIL_DSAR_CAPABILITY = dsar_capability_name("mail")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
|
||||||
|
|
||||||
|
class MailDsarProvider:
|
||||||
|
provider_id = "mail"
|
||||||
|
module_id = "mail"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
email = _subject_email(subject)
|
||||||
|
membership_ids = _membership_ids(subject)
|
||||||
|
references = _mail_references(subject)
|
||||||
|
if email is None and not membership_ids and not references:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
|
||||||
|
def append(record: DsarRecordRef) -> None:
|
||||||
|
if len(records) >= _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Mail DSAR match limit exceeded; narrow the subject selectors."
|
||||||
|
)
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
profiles = _matching_profiles(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
membership_ids=membership_ids,
|
||||||
|
profile_id=references.get("profile"),
|
||||||
|
)
|
||||||
|
for profile in profiles:
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"mail_server_profile",
|
||||||
|
profile.id,
|
||||||
|
"mail_profile",
|
||||||
|
profile.name,
|
||||||
|
{
|
||||||
|
"match_fields": _profile_matching_fields(
|
||||||
|
profile, membership_ids
|
||||||
|
),
|
||||||
|
"name": profile.name,
|
||||||
|
"slug": profile.slug,
|
||||||
|
"description": profile.description,
|
||||||
|
"scope_type": profile.scope_type,
|
||||||
|
"is_active": profile.is_active,
|
||||||
|
"inherit_to_lower_scopes": profile.inherit_to_lower_scopes,
|
||||||
|
},
|
||||||
|
observed_at=profile.updated_at,
|
||||||
|
source_path="/settings?section=mail-profiles",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = _matching_messages(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
email=email,
|
||||||
|
message_id=references.get("message_index"),
|
||||||
|
)
|
||||||
|
for message in messages:
|
||||||
|
matching_headers = _matching_headers(message, email)
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"mailbox_message_index",
|
||||||
|
message.id,
|
||||||
|
"mailbox_message",
|
||||||
|
message.subject or "Mailbox message",
|
||||||
|
{
|
||||||
|
"match_fields": list(matching_headers),
|
||||||
|
"subject": _bounded_text(message.subject),
|
||||||
|
"matching_headers": matching_headers,
|
||||||
|
"date": message.date,
|
||||||
|
"flags": tuple(
|
||||||
|
str(flag)[:100] for flag in (message.flags or ())[:32]
|
||||||
|
),
|
||||||
|
"size_bytes": message.size_bytes,
|
||||||
|
"body_preview": _bounded_text(message.body_preview),
|
||||||
|
"attachment_count": message.attachment_count,
|
||||||
|
"indexed_at": _iso(message.indexed_at),
|
||||||
|
},
|
||||||
|
observed_at=message.updated_at,
|
||||||
|
source_path="/mail",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
bounces = _matching_bounces(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
email=email,
|
||||||
|
bounce_id=references.get("bounce"),
|
||||||
|
)
|
||||||
|
bounce_command_ids = {row.command_id for row in bounces if row.command_id}
|
||||||
|
commands = _matching_commands(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
membership_ids=membership_ids,
|
||||||
|
command_id=references.get("command"),
|
||||||
|
related_command_ids=bounce_command_ids,
|
||||||
|
)
|
||||||
|
command_ids = {row.id for row in commands}
|
||||||
|
for command in commands:
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"mail_delivery_command",
|
||||||
|
command.id,
|
||||||
|
"mail_delivery_evidence",
|
||||||
|
f"Mail {command.command_type} command",
|
||||||
|
{
|
||||||
|
"match_fields": (
|
||||||
|
["created_by_user_id"]
|
||||||
|
if command.created_by_user_id in membership_ids
|
||||||
|
else (
|
||||||
|
["reference"]
|
||||||
|
if command.id == references.get("command")
|
||||||
|
else ["bounce"]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"command_type": command.command_type,
|
||||||
|
"source_module": command.source_module,
|
||||||
|
"source_resource_type": command.source_resource_type,
|
||||||
|
"message_sha256": command.message_sha256,
|
||||||
|
"rfc_message_id": command.rfc_message_id,
|
||||||
|
"message_size_bytes": command.message_size_bytes,
|
||||||
|
"recipient_count": command.recipient_count,
|
||||||
|
"status": command.status,
|
||||||
|
"attempt_count": command.attempt_count,
|
||||||
|
"effect_started_at": _iso(command.effect_started_at),
|
||||||
|
"completed_at": _iso(command.completed_at),
|
||||||
|
"accepted_count": command.accepted_count,
|
||||||
|
"refused_count": command.refused_count,
|
||||||
|
"failure_code": command.failure_code,
|
||||||
|
"payload_purged_at": _iso(command.payload_purged_at),
|
||||||
|
},
|
||||||
|
observed_at=command.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Mail delivery commands are immutable transport, retry, and outcome evidence; encrypted payload retention is governed separately.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for attempt in _command_attempts(db, command_ids):
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"mail_delivery_attempt",
|
||||||
|
attempt.id,
|
||||||
|
"mail_delivery_evidence",
|
||||||
|
f"Mail delivery attempt {attempt.attempt_number}",
|
||||||
|
{
|
||||||
|
"command_id": attempt.command_id,
|
||||||
|
"attempt_number": attempt.attempt_number,
|
||||||
|
"status": attempt.status,
|
||||||
|
"started_at": _iso(attempt.started_at),
|
||||||
|
"effect_started_at": _iso(attempt.effect_started_at),
|
||||||
|
"completed_at": _iso(attempt.completed_at),
|
||||||
|
"accepted_count": attempt.accepted_count,
|
||||||
|
"refused_count": attempt.refused_count,
|
||||||
|
"outcome_code": attempt.outcome_code,
|
||||||
|
},
|
||||||
|
observed_at=attempt.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Per-attempt Mail outcome state is immutable delivery and recovery evidence.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for reconciliation in _command_reconciliations(db, command_ids):
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"mail_delivery_reconciliation",
|
||||||
|
reconciliation.id,
|
||||||
|
"mail_delivery_evidence",
|
||||||
|
"Mail delivery reconciliation",
|
||||||
|
{
|
||||||
|
"command_id": reconciliation.command_id,
|
||||||
|
"decision": reconciliation.decision,
|
||||||
|
},
|
||||||
|
observed_at=reconciliation.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Mail reconciliation decisions are immutable authorization and recovery evidence.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for bounce in bounces:
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"mail_bounce_observation",
|
||||||
|
bounce.id,
|
||||||
|
"mail_bounce_evidence",
|
||||||
|
"Mail bounce observation",
|
||||||
|
{
|
||||||
|
"match_fields": (
|
||||||
|
["recipient"]
|
||||||
|
if email and _normalized_email(bounce.recipient) == email
|
||||||
|
else ["reference"]
|
||||||
|
),
|
||||||
|
"command_id": bounce.command_id
|
||||||
|
if bounce.command_id in command_ids
|
||||||
|
else None,
|
||||||
|
"recipient": email
|
||||||
|
if email and _normalized_email(bounce.recipient) == email
|
||||||
|
else None,
|
||||||
|
"action": bounce.action,
|
||||||
|
"status_code": bounce.status_code,
|
||||||
|
"permanent": bounce.permanent,
|
||||||
|
"observed_at": _iso(bounce.observed_at),
|
||||||
|
"matched": bounce.matched,
|
||||||
|
},
|
||||||
|
observed_at=bounce.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Bounce observations are immutable delivery-status and suppression evidence.",
|
||||||
|
source_path="/mail/bounces",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del session, tenant_id, subject
|
||||||
|
actions = []
|
||||||
|
for record in records:
|
||||||
|
if (
|
||||||
|
record.provider_id != self.provider_id
|
||||||
|
or record.module_id != self.module_id
|
||||||
|
):
|
||||||
|
raise ValueError("Mail DSAR received a foreign provider record.")
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=f"mail:{'retain' if record.immutable_evidence else 'review'}:{record.resource_type}:{record.resource_id}",
|
||||||
|
provider_id="mail",
|
||||||
|
module_id="mail",
|
||||||
|
kind="retain" if record.immutable_evidence else "manual_review",
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=f"{'Retain' if record.immutable_evidence else 'Review'} {record.title}",
|
||||||
|
rationale=record.retention_reason
|
||||||
|
or "Mailbox indexes and personal profiles must be reviewed through Mail and the authoritative external mailbox lifecycle.",
|
||||||
|
executable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
del session, tenant_id, subject, request_id
|
||||||
|
return tuple(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary="Mail erasure requires an authorized Mail/external-mailbox lifecycle action; the DSAR provider does not mutate it directly.",
|
||||||
|
)
|
||||||
|
for action in actions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_profiles(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
membership_ids: set[str],
|
||||||
|
profile_id: str | None,
|
||||||
|
) -> list[MailServerProfile]:
|
||||||
|
conditions = []
|
||||||
|
if profile_id:
|
||||||
|
conditions.append(MailServerProfile.id == profile_id)
|
||||||
|
if membership_ids:
|
||||||
|
conditions.extend(
|
||||||
|
(
|
||||||
|
MailServerProfile.created_by_user_id.in_(membership_ids),
|
||||||
|
MailServerProfile.updated_by_user_id.in_(membership_ids),
|
||||||
|
(MailServerProfile.scope_type == "user")
|
||||||
|
& MailServerProfile.scope_id.in_(membership_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not conditions:
|
||||||
|
return []
|
||||||
|
return _bounded_rows(
|
||||||
|
session.query(MailServerProfile)
|
||||||
|
.filter(MailServerProfile.tenant_id == tenant_id, or_(*conditions))
|
||||||
|
.order_by(MailServerProfile.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_matching_fields(
|
||||||
|
row: MailServerProfile, membership_ids: set[str]
|
||||||
|
) -> list[str]:
|
||||||
|
fields = []
|
||||||
|
if row.scope_type == "user" and row.scope_id in membership_ids:
|
||||||
|
fields.append("scope_id")
|
||||||
|
for field in ("created_by_user_id", "updated_by_user_id"):
|
||||||
|
if getattr(row, field) in membership_ids:
|
||||||
|
fields.append(field)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_messages(
|
||||||
|
session: Session, *, tenant_id: str, email: str | None, message_id: str | None
|
||||||
|
) -> list[MailMailboxMessageIndex]:
|
||||||
|
conditions = []
|
||||||
|
if message_id:
|
||||||
|
conditions.append(MailMailboxMessageIndex.id == message_id)
|
||||||
|
if email:
|
||||||
|
pattern = f"%{_escape_like(email)}%"
|
||||||
|
conditions.extend(
|
||||||
|
func.lower(field).like(pattern, escape="\\")
|
||||||
|
for field in (
|
||||||
|
MailMailboxMessageIndex.from_header,
|
||||||
|
MailMailboxMessageIndex.to_header,
|
||||||
|
MailMailboxMessageIndex.cc_header,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not conditions:
|
||||||
|
return []
|
||||||
|
candidates = _bounded_rows(
|
||||||
|
session.query(MailMailboxMessageIndex)
|
||||||
|
.filter(MailMailboxMessageIndex.tenant_id == tenant_id, or_(*conditions))
|
||||||
|
.order_by(MailMailboxMessageIndex.id)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
row
|
||||||
|
for row in candidates
|
||||||
|
if row.id == message_id or _matching_headers(row, email)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_headers(
|
||||||
|
row: MailMailboxMessageIndex, email: str | None
|
||||||
|
) -> dict[str, list[dict[str, str | None]]]:
|
||||||
|
if email is None:
|
||||||
|
return {}
|
||||||
|
result = {}
|
||||||
|
for role, value in (
|
||||||
|
("from", row.from_header),
|
||||||
|
("to", row.to_header),
|
||||||
|
("cc", row.cc_header),
|
||||||
|
):
|
||||||
|
matches = [
|
||||||
|
{"email": address.casefold(), "name": name or None}
|
||||||
|
for name, address in getaddresses([value or ""])
|
||||||
|
if address.casefold() == email
|
||||||
|
]
|
||||||
|
if matches:
|
||||||
|
result[role] = matches[:64]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_bounces(
|
||||||
|
session: Session, *, tenant_id: str, email: str | None, bounce_id: str | None
|
||||||
|
) -> list[MailBounceObservation]:
|
||||||
|
conditions = []
|
||||||
|
if bounce_id:
|
||||||
|
conditions.append(MailBounceObservation.id == bounce_id)
|
||||||
|
if email:
|
||||||
|
conditions.append(func.lower(MailBounceObservation.recipient) == email)
|
||||||
|
if not conditions:
|
||||||
|
return []
|
||||||
|
return _bounded_rows(
|
||||||
|
session.query(MailBounceObservation)
|
||||||
|
.filter(MailBounceObservation.tenant_id == tenant_id, or_(*conditions))
|
||||||
|
.order_by(MailBounceObservation.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_commands(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
membership_ids: set[str],
|
||||||
|
command_id: str | None,
|
||||||
|
related_command_ids: set[str],
|
||||||
|
) -> list[MailDeliveryCommand]:
|
||||||
|
conditions = []
|
||||||
|
if command_id:
|
||||||
|
conditions.append(MailDeliveryCommand.id == command_id)
|
||||||
|
if related_command_ids:
|
||||||
|
conditions.append(MailDeliveryCommand.id.in_(related_command_ids))
|
||||||
|
if membership_ids:
|
||||||
|
conditions.append(MailDeliveryCommand.created_by_user_id.in_(membership_ids))
|
||||||
|
if not conditions:
|
||||||
|
return []
|
||||||
|
return _bounded_rows(
|
||||||
|
session.query(MailDeliveryCommand)
|
||||||
|
.filter(MailDeliveryCommand.tenant_id == tenant_id, or_(*conditions))
|
||||||
|
.order_by(MailDeliveryCommand.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _command_attempts(
|
||||||
|
session: Session, command_ids: set[str]
|
||||||
|
) -> list[MailDeliveryAttempt]:
|
||||||
|
if not command_ids:
|
||||||
|
return []
|
||||||
|
return _bounded_rows(
|
||||||
|
session.query(MailDeliveryAttempt)
|
||||||
|
.filter(MailDeliveryAttempt.command_id.in_(command_ids))
|
||||||
|
.order_by(MailDeliveryAttempt.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _command_reconciliations(
|
||||||
|
session: Session, command_ids: set[str]
|
||||||
|
) -> list[MailDeliveryReconciliation]:
|
||||||
|
if not command_ids:
|
||||||
|
return []
|
||||||
|
return _bounded_rows(
|
||||||
|
session.query(MailDeliveryReconciliation)
|
||||||
|
.filter(MailDeliveryReconciliation.command_id.in_(command_ids))
|
||||||
|
.order_by(MailDeliveryReconciliation.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_references(subject: DsarSubjectRef) -> dict[str, str]:
|
||||||
|
aliases = {
|
||||||
|
"mail.profile": "profile",
|
||||||
|
"mail.message_index": "message_index",
|
||||||
|
"mail.delivery_command": "command",
|
||||||
|
"mail.bounce_observation": "bounce",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
target: value
|
||||||
|
for key, target in aliases.items()
|
||||||
|
if (value := str(subject.external_references.get(key) or "").strip())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _membership_ids(subject: DsarSubjectRef) -> set[str]:
|
||||||
|
values = [subject.membership_id]
|
||||||
|
values.extend(
|
||||||
|
subject.external_references.get(key)
|
||||||
|
for key in (
|
||||||
|
"mail.user",
|
||||||
|
"mail.membership",
|
||||||
|
"access.membership",
|
||||||
|
"membership_id",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {value for item in values if (value := str(item or "").strip())}
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_email(subject: DsarSubjectRef) -> str | None:
|
||||||
|
values = [subject.email, subject.external_references.get("mail.email")]
|
||||||
|
normalized = {email for value in values if (email := _normalized_email(value))}
|
||||||
|
return normalized.pop() if len(normalized) == 1 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 _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="mail",
|
||||||
|
module_id="mail",
|
||||||
|
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("Mail 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(
|
||||||
|
"Mail 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 _escape_like(value: str) -> str:
|
||||||
|
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
|
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__ = ["MAIL_DSAR_CAPABILITY", "MailDsarProvider"]
|
||||||
@@ -15,6 +15,7 @@ from govoplan_core.core.mail import (
|
|||||||
from govoplan_core.core.postbox import CAPABILITY_POSTBOX_DELIVERY
|
from govoplan_core.core.postbox import CAPABILITY_POSTBOX_DELIVERY
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
DocumentationCondition,
|
DocumentationCondition,
|
||||||
DocumentationConfigurationProviderRegistration,
|
DocumentationConfigurationProviderRegistration,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
@@ -54,6 +55,7 @@ from govoplan_mail.backend.provider_state import (
|
|||||||
smtp_provider_states,
|
smtp_provider_states,
|
||||||
)
|
)
|
||||||
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
||||||
|
from govoplan_mail.backend.dsar_provider import MAIL_DSAR_CAPABILITY
|
||||||
from govoplan_mail.backend.search_source import create_mail_search_source
|
from govoplan_mail.backend.search_source import create_mail_search_source
|
||||||
|
|
||||||
|
|
||||||
@@ -105,6 +107,13 @@ def _configuration_provider(context: ModuleContext) -> object:
|
|||||||
return SqlMailConfigurationProvider()
|
return SqlMailConfigurationProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_dsar_provider(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_mail.backend.dsar_provider import MailDsarProvider
|
||||||
|
|
||||||
|
return MailDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
@@ -311,7 +320,7 @@ IMAP_PROVIDER = ExternalProviderDeclaration(
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="mail",
|
id="mail",
|
||||||
name="Mail",
|
name="Mail",
|
||||||
version="0.1.18",
|
version="0.1.19",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"),
|
optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
@@ -321,6 +330,7 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceProvider(name="mail.notification_delivery", version="0.1.0"),
|
ModuleInterfaceProvider(name="mail.notification_delivery", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="mail.bounce_processing", version="0.1.0"),
|
ModuleInterfaceProvider(name="mail.bounce_processing", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_MAIL_POSTBOX_BRIDGE, version="1.0.0"),
|
ModuleInterfaceProvider(name=CAPABILITY_MAIL_POSTBOX_BRIDGE, version="1.0.0"),
|
||||||
|
ModuleInterfaceProvider(name=MAIL_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -473,8 +483,57 @@ manifest = ModuleManifest(
|
|||||||
"govoplan_mail.backend.postbox_bridge",
|
"govoplan_mail.backend.postbox_bridge",
|
||||||
fromlist=["create_postbox_bridge"],
|
fromlist=["create_postbox_bridge"],
|
||||||
).create_postbox_bridge(context),
|
).create_postbox_bridge(context),
|
||||||
|
MAIL_DSAR_CAPABILITY: _mail_dsar_provider,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
MAIL_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Mail data-subject request provider",
|
||||||
|
summary="Finds isolated mailbox-index, personal-profile, delivery, reconciliation, and bounce metadata without exposing transport secrets.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("privacy_officer", "mail_admin", "records_manager"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="mail.privacy.data-subject-requests",
|
||||||
|
title="Review Mail data in a data-subject request",
|
||||||
|
summary="Collect tenant-scoped Mail metadata while preserving transport evidence and external-mailbox authority.",
|
||||||
|
body=(
|
||||||
|
"Mail's DSAR provider searches the effective tenant by normalized header or bounce-recipient email, direct membership references, and namespaced Mail profile, message-index, delivery-command, or bounce references. It isolates only matching From, To, and Cc parties and returns bounded message-index content, safe personal-profile metadata, and delivery, attempt, reconciliation, and bounce outcomes. It excludes SMTP/IMAP configuration, usernames and credentials, encrypted messages and envelopes, refusal detail, mailbox folders and UIDs, endpoint and credential identifiers, idempotency and worker claims, diagnostics, error text, and opaque evidence. "
|
||||||
|
"Delivery attempts, reconciliation decisions, and bounce observations remain retained as immutable transport and recovery evidence. Mailbox indexes and personal profiles require manual review through Mail and the authoritative external mailbox. The provider performs no direct erasure because deleting a derived index alone would not delete its external source, while changing a profile or delivery command can affect credentials, other users, and preserved evidence."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("privacy_officer", "mail_admin", "records_manager", "operator"),
|
||||||
|
order=35,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("mail", "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="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
|
||||||
|
),
|
||||||
|
related_modules=("access", "audit", "campaigns", "postbox"),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/admin?section=tenant-data-subject-requests",
|
||||||
|
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||||
|
"steps": [
|
||||||
|
"Run the Mail provider search and review mailbox-index, profile, delivery, reconciliation, and bounce dispositions.",
|
||||||
|
"Retain immutable transport evidence with its reason.",
|
||||||
|
"Coordinate approved mailbox content deletion with the authoritative external mailbox and then refresh the derived index.",
|
||||||
|
"Use Mail profile lifecycle controls for approved personal-profile changes; do not edit encrypted payload or evidence rows directly.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Encrypted outbound payloads cannot be searched by recipient without an independently corroborated Mail command reference.",
|
||||||
|
"External mailbox deletion is outside the DSAR provider and must be coordinated with the configured provider.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="mail.postbox.bridge",
|
id="mail.postbox.bridge",
|
||||||
title="Bridge selected Mail observations into Postbox",
|
title="Bridge selected Mail observations into Postbox",
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
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_mail.backend.db.models import (
|
||||||
|
MailBounceObservation,
|
||||||
|
MailDeliveryAttempt,
|
||||||
|
MailDeliveryCommand,
|
||||||
|
MailDeliveryReconciliation,
|
||||||
|
MailMailboxMessageIndex,
|
||||||
|
MailServerProfile,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.dsar_provider import MAIL_DSAR_CAPABILITY, MailDsarProvider
|
||||||
|
from govoplan_mail.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider, active=True):
|
||||||
|
self.provider = provider
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (MAIL_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
assert name == MAIL_DSAR_CAPABILITY
|
||||||
|
return "mail"
|
||||||
|
|
||||||
|
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": ("mail",) if active else ()}
|
||||||
|
)()
|
||||||
|
|
||||||
|
return Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
assert name == MAIL_DSAR_CAPABILITY
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
|
||||||
|
class MailDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
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__,
|
||||||
|
MailServerProfile.__table__,
|
||||||
|
MailMailboxMessageIndex.__table__,
|
||||||
|
MailDeliveryCommand.__table__,
|
||||||
|
MailDeliveryAttempt.__table__,
|
||||||
|
MailDeliveryReconciliation.__table__,
|
||||||
|
MailBounceObservation.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="subject@example.test",
|
||||||
|
normalized_email="subject@example.test",
|
||||||
|
display_name="Subject",
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=account.id,
|
||||||
|
email="subject@example.test",
|
||||||
|
display_name="Subject",
|
||||||
|
)
|
||||||
|
profile = MailServerProfile(
|
||||||
|
id="profile-subject",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id=user.id,
|
||||||
|
name="Personal mail",
|
||||||
|
slug="personal",
|
||||||
|
smtp_config={"host": "smtp-secret-do-not-export"},
|
||||||
|
smtp_username="smtp-user-do-not-export",
|
||||||
|
smtp_password_encrypted="smtp-cipher-do-not-export",
|
||||||
|
imap_config={"host": "imap-secret-do-not-export"},
|
||||||
|
imap_username="imap-user-do-not-export",
|
||||||
|
imap_password_encrypted="imap-cipher-do-not-export",
|
||||||
|
created_by_user_id=user.id,
|
||||||
|
)
|
||||||
|
message = MailMailboxMessageIndex(
|
||||||
|
id="message-subject",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id=profile.id,
|
||||||
|
folder="INBOX-secret-do-not-export",
|
||||||
|
uid="uid-secret-do-not-export",
|
||||||
|
uid_int=1,
|
||||||
|
sort_position=1,
|
||||||
|
subject="Subject notice",
|
||||||
|
from_header="Office <office@example.test>",
|
||||||
|
to_header="Subject Person <Subject@Example.Test>",
|
||||||
|
cc_header="Unrelated Person <other@example.test>",
|
||||||
|
date="2026-08-20",
|
||||||
|
message_id="message-locator-do-not-export",
|
||||||
|
flags=["\\Seen"],
|
||||||
|
size_bytes=42,
|
||||||
|
body_preview="Message preview for the subject",
|
||||||
|
attachment_count=1,
|
||||||
|
indexed_at=now,
|
||||||
|
)
|
||||||
|
unrelated = MailMailboxMessageIndex(
|
||||||
|
id="message-other",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id=profile.id,
|
||||||
|
folder="INBOX",
|
||||||
|
uid="2",
|
||||||
|
uid_int=2,
|
||||||
|
sort_position=2,
|
||||||
|
subject="Unrelated message do not export",
|
||||||
|
from_header="other@example.test",
|
||||||
|
to_header="someone@example.test",
|
||||||
|
indexed_at=now,
|
||||||
|
)
|
||||||
|
tenant_two_profile = MailServerProfile(
|
||||||
|
id="profile-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
name="Tenant two",
|
||||||
|
slug="tenant-two",
|
||||||
|
smtp_config={},
|
||||||
|
)
|
||||||
|
tenant_two = MailMailboxMessageIndex(
|
||||||
|
id="message-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
profile_id=tenant_two_profile.id,
|
||||||
|
folder="INBOX",
|
||||||
|
uid="1",
|
||||||
|
uid_int=1,
|
||||||
|
sort_position=1,
|
||||||
|
subject="Tenant two message do not export",
|
||||||
|
to_header="subject@example.test",
|
||||||
|
indexed_at=now,
|
||||||
|
)
|
||||||
|
command = MailDeliveryCommand(
|
||||||
|
id="command-subject",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
command_type="send",
|
||||||
|
source_module="notifications",
|
||||||
|
source_resource_type="notification",
|
||||||
|
idempotency_key="idempotency-do-not-export",
|
||||||
|
canonical_request_hash="a" * 64,
|
||||||
|
profile_id=profile.id,
|
||||||
|
expected_smtp_transport_revision="revision-secret",
|
||||||
|
envelope_recipients_encrypted="recipient-cipher-do-not-export",
|
||||||
|
message_encrypted="message-cipher-do-not-export",
|
||||||
|
message_sha256="b" * 64,
|
||||||
|
rfc_message_id="rfc-message-id",
|
||||||
|
message_size_bytes=100,
|
||||||
|
recipient_count=1,
|
||||||
|
status="succeeded",
|
||||||
|
attempt_count=1,
|
||||||
|
accepted_count=1,
|
||||||
|
created_by_user_id=user.id,
|
||||||
|
completed_at=now,
|
||||||
|
expires_at=now + timedelta(days=30),
|
||||||
|
)
|
||||||
|
attempt = MailDeliveryAttempt(
|
||||||
|
id="attempt-subject",
|
||||||
|
command_id=command.id,
|
||||||
|
attempt_number=1,
|
||||||
|
worker_id="worker-do-not-export",
|
||||||
|
status="succeeded",
|
||||||
|
started_at=now,
|
||||||
|
completed_at=now,
|
||||||
|
accepted_count=1,
|
||||||
|
diagnostic_summary="diagnostic-do-not-export",
|
||||||
|
)
|
||||||
|
reconciliation = MailDeliveryReconciliation(
|
||||||
|
id="reconciliation-subject",
|
||||||
|
command_id=command.id,
|
||||||
|
decision="confirmed_sent",
|
||||||
|
evidence_reference="private-reference-do-not-export",
|
||||||
|
note_encrypted="private-note-do-not-export",
|
||||||
|
created_by_user_id=user.id,
|
||||||
|
)
|
||||||
|
bounce = MailBounceObservation(
|
||||||
|
id="bounce-subject",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id=profile.id,
|
||||||
|
folder="bounce-folder-do-not-export",
|
||||||
|
uid="bounce-uid-do-not-export",
|
||||||
|
fingerprint="c" * 64,
|
||||||
|
raw_sha256="d" * 64,
|
||||||
|
original_message_id="original-id-do-not-export",
|
||||||
|
command_id=command.id,
|
||||||
|
recipient="subject@example.test",
|
||||||
|
action="failed",
|
||||||
|
status_code="5.1.1",
|
||||||
|
diagnostic="bounce-diagnostic-do-not-export",
|
||||||
|
permanent=True,
|
||||||
|
observed_at=now,
|
||||||
|
matched=True,
|
||||||
|
evidence={"secret": "bounce-evidence-do-not-export"},
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
account,
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
message,
|
||||||
|
unrelated,
|
||||||
|
tenant_two_profile,
|
||||||
|
tenant_two,
|
||||||
|
command,
|
||||||
|
attempt,
|
||||||
|
reconciliation,
|
||||||
|
bounce,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.provider = MailDsarProvider()
|
||||||
|
self.subject = DsarSubjectRef(
|
||||||
|
membership_id=user.id, email="subject@example.test"
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_manifest_and_minimized_tenant_scoped_search(self):
|
||||||
|
self.assertIn(
|
||||||
|
MAIL_DSAR_CAPABILITY, {item.name for item in manifest.provides_interfaces}
|
||||||
|
)
|
||||||
|
self.assertIsInstance(
|
||||||
|
manifest.capability_factories[MAIL_DSAR_CAPABILITY](None), DsarProvider
|
||||||
|
)
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session, tenant_id="tenant-1", subject=self.subject
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"mail_server_profile",
|
||||||
|
"mailbox_message_index",
|
||||||
|
"mail_delivery_command",
|
||||||
|
"mail_delivery_attempt",
|
||||||
|
"mail_delivery_reconciliation",
|
||||||
|
"mail_bounce_observation",
|
||||||
|
}.issubset({r.resource_type for r in records})
|
||||||
|
)
|
||||||
|
serialized = repr([record.to_dict() for record in records])
|
||||||
|
for hidden in (
|
||||||
|
"other@example.test",
|
||||||
|
"Unrelated Person",
|
||||||
|
"message-other",
|
||||||
|
"Unrelated message do not export",
|
||||||
|
"message-tenant-2",
|
||||||
|
"Tenant two message do not export",
|
||||||
|
"smtp-secret-do-not-export",
|
||||||
|
"smtp-user-do-not-export",
|
||||||
|
"smtp-cipher-do-not-export",
|
||||||
|
"imap-secret-do-not-export",
|
||||||
|
"imap-user-do-not-export",
|
||||||
|
"imap-cipher-do-not-export",
|
||||||
|
"INBOX-secret-do-not-export",
|
||||||
|
"uid-secret-do-not-export",
|
||||||
|
"message-locator-do-not-export",
|
||||||
|
"idempotency-do-not-export",
|
||||||
|
"recipient-cipher-do-not-export",
|
||||||
|
"message-cipher-do-not-export",
|
||||||
|
"worker-do-not-export",
|
||||||
|
"diagnostic-do-not-export",
|
||||||
|
"private-reference-do-not-export",
|
||||||
|
"private-note-do-not-export",
|
||||||
|
"bounce-folder-do-not-export",
|
||||||
|
"bounce-uid-do-not-export",
|
||||||
|
"original-id-do-not-export",
|
||||||
|
"bounce-diagnostic-do-not-export",
|
||||||
|
"bounce-evidence-do-not-export",
|
||||||
|
):
|
||||||
|
self.assertNotIn(hidden, serialized)
|
||||||
|
|
||||||
|
def test_conflicting_email_fails_closed(self):
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
email="subject@example.test",
|
||||||
|
external_references={"mail.email": "other@example.test"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual((), records)
|
||||||
|
|
||||||
|
def test_plan_preserves_evidence_and_executes_nothing(self):
|
||||||
|
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"}.issubset({a.kind for a in actions}))
|
||||||
|
self.assertFalse(any(action.executable for action in actions))
|
||||||
|
|
||||||
|
def test_core_workflow_discovers_active_and_skips_disabled_provider(self):
|
||||||
|
request = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-MAIL-1",
|
||||||
|
request_kind="access_and_erasure",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Authorized request",
|
||||||
|
legal_basis="GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=request,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(["mail"], request.coverage["covered_modules"])
|
||||||
|
plan_data_subject_erasure(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=request,
|
||||||
|
expected_revision=2,
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
any(action["executable"] for action in request.erasure_plan["actions"])
|
||||||
|
)
|
||||||
|
disabled = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-MAIL-OFF",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Coverage",
|
||||||
|
legal_basis=None,
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="officer",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, active=False),
|
||||||
|
row=disabled,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[MAIL_DSAR_CAPABILITY], disabled.coverage["inactive_provider_capabilities"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user