feat(dist-lists): add governed DSAR coverage

This commit is contained in:
2026-08-21 13:33:30 +02:00
parent bcc6bdd2d7
commit b89d9fb434
3 changed files with 1112 additions and 9 deletions
@@ -0,0 +1,718 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parseaddr
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_dist_lists.backend.db.models import (
DistributionList,
DistributionListEntry,
DistributionListRevision,
DistributionListSnapshot,
)
DIST_LISTS_DSAR_CAPABILITY = dsar_capability_name("dist_lists")
_MAX_RECORDS = 5_000
_MAX_SNAPSHOTS = 5_000
_MAX_SNAPSHOT_RECIPIENTS = 20_000
_MAX_CHANNELS = 20
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _Selectors:
account_id: str | None
identity_id: str | None
email: str | None
contact_id: str | None
list_id: str | None
snapshot_id: str | None
entry_id: str | None
@property
def has_subject_identity(self) -> bool:
return bool(
self.account_id or self.identity_id or self.email or self.contact_id
)
class DistributionListsDsarProvider:
provider_id = "dist_lists"
module_id = "dist_lists"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _selectors(subject)
if selectors is None or not selectors.has_subject_identity:
return ()
records: list[DsarRecordRef] = []
seen: set[tuple[str, str]] = set()
def append(record: DsarRecordRef) -> None:
key = (record.resource_type, record.resource_id)
if key in seen:
return
if len(records) >= _MAX_RECORDS:
raise ValueError(
"Distribution Lists DSAR result limit exceeded; narrow selectors."
)
seen.add(key)
records.append(record)
self._append_subject_scopes(
db, append=append, tenant_id=tenant_id, selectors=selectors
)
self._append_definition_entries(
db, append=append, tenant_id=tenant_id, selectors=selectors
)
self._append_snapshot_recipients(
db, append=append, tenant_id=tenant_id, selectors=selectors
)
self._append_actor_attribution(
db, append=append, tenant_id=tenant_id, selectors=selectors
)
return tuple(
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
selectors = _selectors(subject)
if selectors is None or not selectors.has_subject_identity:
raise ValueError("Distribution Lists DSAR subject selectors are invalid.")
actions = []
for record in records:
_validate_record(record)
review = record.resource_type in {
"distribution_list_subject_scope",
"distribution_list_definition_entry",
}
actions.append(
DsarErasureActionRef(
action_id=(
f"dist_lists:{'manual_review' if review else 'retain'}:"
f"{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="manual_review" if review else "retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=("Review " if review else "Retain ") + record.title,
rationale=(
"Create a new list revision or retire the subject-owned list only "
"after reviewing downstream consumers; prior revisions remain evidence."
if review
else record.retention_reason
or "Distribution-list execution evidence remains retained."
),
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 tenant_id
_session(session)
selectors = _selectors(subject)
if selectors is None or not selectors.has_subject_identity:
raise ValueError("Distribution Lists DSAR subject selectors are invalid.")
results = []
for action in actions:
_validate_action(action)
if action.executable or action.kind not in {"manual_review", "retain"}:
raise ValueError(
"Distribution Lists DSAR publishes non-executable actions only."
)
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Distribution-list data remains unchanged pending a governed "
"revision and downstream-consumer review."
if action.kind == "manual_review"
else "Immutable distribution-list evidence remains retained."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _append_subject_scopes(
self,
db: Session,
*,
append,
tenant_id: str,
selectors: _Selectors,
) -> None:
if not selectors.account_id or selectors.snapshot_id or selectors.entry_id:
return
query = db.query(DistributionList).filter(
DistributionList.tenant_id == tenant_id,
DistributionList.scope_type == "user",
DistributionList.scope_id == selectors.account_id,
)
query = _list_filter(query, DistributionList, selectors)
for row in _limited(query, DistributionList.updated_at, DistributionList.id):
append(
_record(
"distribution_list_subject_scope",
row.id,
"subject_owned_distribution_list",
"Subject-scoped distribution list",
{
"distribution_list_id": row.id,
"scope_type": row.scope_type,
"scope_id": row.scope_id,
"status": row.status,
"current_revision": row.current_revision,
"deleted_at": _iso(row.deleted_at),
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
},
observed_at=row.updated_at,
retention_reason=(
"List retirement requires downstream-consumer and retained-snapshot review."
),
)
)
def _append_definition_entries(
self,
db: Session,
*,
append,
tenant_id: str,
selectors: _Selectors,
) -> None:
if selectors.snapshot_id:
return
query = db.query(DistributionListEntry).filter(
DistributionListEntry.tenant_id == tenant_id
)
if selectors.entry_id:
query = query.filter(DistributionListEntry.id == selectors.entry_id)
if selectors.list_id:
query = query.join(DistributionListRevision).filter(
DistributionListRevision.distribution_list_id == selectors.list_id
)
for row in _limited(
query, DistributionListEntry.created_at, DistributionListEntry.id
):
if not _entry_matches(row, selectors):
continue
append(
_record(
"distribution_list_definition_entry",
row.id,
"distribution_list_recipient_definition",
"Distribution-list recipient definition",
{
"entry_id": row.id,
"revision_id": row.revision_id,
"kind": row.kind,
"mode": row.mode,
"source_provider": row.source_provider,
"source_resource_type": row.source_resource_type,
"source_resource_id": _bounded(row.source_resource_id, 500),
"purpose": _bounded(row.purpose, 120),
"requested_channels": [
str(value)[:40] for value in row.requested_channels[:10]
],
"effective_from": _iso(row.effective_from),
"effective_until": _iso(row.effective_until),
"created_at": _iso(row.created_at),
},
observed_at=row.created_at,
immutable=True,
retention_reason=(
"Saved distribution-list revisions remain evidence for existing snapshots."
),
)
)
def _append_snapshot_recipients(
self,
db: Session,
*,
append,
tenant_id: str,
selectors: _Selectors,
) -> None:
if selectors.entry_id:
return
query = db.query(DistributionListSnapshot).filter(
DistributionListSnapshot.tenant_id == tenant_id
)
query = _list_filter(query, DistributionListSnapshot, selectors)
if selectors.snapshot_id:
query = query.filter(DistributionListSnapshot.id == selectors.snapshot_id)
snapshots = (
query.order_by(
DistributionListSnapshot.created_at, DistributionListSnapshot.id
)
.limit(_MAX_SNAPSHOTS + 1)
.all()
)
if len(snapshots) > _MAX_SNAPSHOTS:
raise ValueError(
"Distribution Lists DSAR snapshot scan limit exceeded; narrow selectors."
)
for snapshot in snapshots:
total = len(snapshot.recipients) + len(snapshot.excluded)
if total > _MAX_SNAPSHOT_RECIPIENTS:
raise ValueError(
"Distribution Lists DSAR snapshot recipient limit exceeded."
)
for placement, rows in (
("included", snapshot.recipients),
("excluded", snapshot.excluded),
):
for index, recipient in enumerate(rows):
if not isinstance(recipient, dict) or not _recipient_matches(
recipient, selectors
):
continue
append(
_snapshot_recipient_record(
snapshot,
recipient=recipient,
placement=placement,
index=index,
)
)
def _append_actor_attribution(
self,
db: Session,
*,
append,
tenant_id: str,
selectors: _Selectors,
) -> None:
if not selectors.account_id:
return
if selectors.entry_id:
return
account_id = selectors.account_id
if not selectors.snapshot_id:
lists = db.query(DistributionList).filter(
DistributionList.tenant_id == tenant_id,
or_(
DistributionList.created_by_account_id == account_id,
DistributionList.updated_by_account_id == account_id,
),
)
lists = _list_filter(lists, DistributionList, selectors)
for row in _limited(
lists, DistributionList.updated_at, DistributionList.id
):
fields = []
if row.created_by_account_id == account_id:
fields.append("created_by_account_id")
if row.updated_by_account_id == account_id:
fields.append("updated_by_account_id")
append(
_attribution_record(
"distribution_list_actor_attribution",
row.id,
"Distribution-list actor attribution",
{
"distribution_list_id": row.id,
"activities": fields,
"status": row.status,
"current_revision": row.current_revision,
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
},
row.updated_at,
)
)
revisions = db.query(DistributionListRevision).filter(
DistributionListRevision.tenant_id == tenant_id,
DistributionListRevision.created_by_account_id == account_id,
)
if selectors.list_id:
revisions = revisions.filter(
DistributionListRevision.distribution_list_id == selectors.list_id
)
for row in _limited(
revisions,
DistributionListRevision.created_at,
DistributionListRevision.id,
):
append(
_attribution_record(
"distribution_list_revision_actor_attribution",
row.id,
"Distribution-list revision actor attribution",
{
"distribution_list_id": row.distribution_list_id,
"revision_id": row.id,
"revision": row.revision,
"definition_kind": row.definition_kind,
"activity": "created_revision",
"created_at": _iso(row.created_at),
},
row.created_at,
)
)
snapshots = db.query(DistributionListSnapshot).filter(
DistributionListSnapshot.tenant_id == tenant_id,
DistributionListSnapshot.created_by_account_id == account_id,
)
snapshots = _list_filter(snapshots, DistributionListSnapshot, selectors)
if selectors.snapshot_id:
snapshots = snapshots.filter(
DistributionListSnapshot.id == selectors.snapshot_id
)
for row in _limited(
snapshots, DistributionListSnapshot.created_at, DistributionListSnapshot.id
):
append(
_attribution_record(
"distribution_list_snapshot_actor_attribution",
row.id,
"Distribution-list snapshot actor attribution",
{
"distribution_list_id": row.distribution_list_id,
"snapshot_id": row.id,
"revision_number": row.revision_number,
"effective_at": _iso(row.effective_at),
"activity": "froze_expansion_snapshot",
"created_at": _iso(row.created_at),
},
row.created_at,
)
)
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("dist_lists.account"),
references.get("access.account"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("dist_lists.identity"),
references.get("identity.id"),
),
"email": _coalesce_email(subject.email, references.get("dist_lists.email")),
"contact_id": _coalesce(
references.get("dist_lists.contact"),
references.get("addresses.contact"),
),
"list_id": _coalesce(
references.get("dist_lists.list"),
references.get("dist_lists.list_id"),
),
"snapshot_id": _coalesce(
references.get("dist_lists.snapshot"),
references.get("dist_lists.snapshot_id"),
),
"entry_id": _coalesce(
references.get("dist_lists.entry"),
references.get("dist_lists.entry_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
return _Selectors(**{key: _optional(value) for key, value in values.items()})
def _entry_matches(row: DistributionListEntry, selectors: _Selectors) -> bool:
resource_id = row.source_resource_id.strip()
if row.kind == "raw_email" and selectors.email:
return _normalized_email(resource_id) == selectors.email
if row.kind == "internal_mail" and selectors.account_id:
return resource_id == selectors.account_id
if row.kind == "idm_identity" and selectors.identity_id:
return resource_id == selectors.identity_id
if row.kind == "address_contact" and selectors.contact_id:
return resource_id == selectors.contact_id
return False
def _recipient_matches(recipient: Mapping[str, object], selectors: _Selectors) -> bool:
if (
selectors.account_id
and _text(recipient.get("account_id")) == selectors.account_id
):
return True
if (
selectors.identity_id
and _text(recipient.get("identity_id")) == selectors.identity_id
):
return True
if (
selectors.contact_id
and _text(recipient.get("contact_id")) == selectors.contact_id
):
return True
if not selectors.email:
return False
if _normalized_recipient_key(recipient.get("recipient_key")) == selectors.email:
return True
channels = recipient.get("channels")
if not isinstance(channels, list) or len(channels) > _MAX_CHANNELS:
return False
return any(
isinstance(channel, dict)
and (
_normalized_email(channel.get("target")) == selectors.email
or _normalized_recipient_key(channel.get("target_key")) == selectors.email
)
for channel in channels
)
def _snapshot_recipient_record(
snapshot: DistributionListSnapshot,
*,
recipient: Mapping[str, object],
placement: str,
index: int,
) -> DsarRecordRef:
channels = recipient.get("channels")
safe_channels = []
if isinstance(channels, list):
if len(channels) > _MAX_CHANNELS:
raise ValueError(
"Distribution Lists DSAR recipient channel limit exceeded."
)
for channel in channels:
if not isinstance(channel, dict):
continue
safe_channels.append(
{
"channel": _bounded(channel.get("channel"), 40),
"target": _bounded(channel.get("target"), 1_000),
"target_key": _bounded(channel.get("target_key"), 1_000),
"status": _bounded(channel.get("status"), 40),
"contact_point_id": _bounded(channel.get("contact_point_id"), 120),
"locale": _bounded(channel.get("locale"), 40),
"reason_code": _bounded(channel.get("reason_code"), 120),
}
)
return _record(
"distribution_list_snapshot_recipient",
f"{snapshot.id}:{placement}:{index}",
"frozen_recipient_projection",
"Frozen distribution-list recipient",
{
"snapshot_id": snapshot.id,
"distribution_list_id": snapshot.distribution_list_id,
"revision_number": snapshot.revision_number,
"effective_at": _iso(snapshot.effective_at),
"placement": placement,
"recipient_key": _bounded(recipient.get("recipient_key"), 1_000),
"display_name": _bounded(recipient.get("display_name"), 500),
"status": _bounded(recipient.get("status"), 40),
"account_id": _bounded(recipient.get("account_id"), 120),
"identity_id": _bounded(recipient.get("identity_id"), 120),
"contact_id": _bounded(recipient.get("contact_id"), 120),
"channels": safe_channels,
},
observed_at=snapshot.effective_at,
immutable=True,
retention_reason=(
"Frozen recipient decisions are immutable downstream execution evidence."
),
)
def _attribution_record(
resource_type: str,
resource_id: str,
title: str,
data: dict[str, object],
observed_at: datetime | None,
) -> DsarRecordRef:
return _record(
resource_type,
resource_id,
"distribution_list_governance_attribution",
title,
data,
observed_at=observed_at,
immutable=True,
retention_reason=(
"Distribution-list lifecycle attribution is retained for accountability."
),
)
def _record(
resource_type: str,
resource_id: str,
category: str,
title: str,
data: dict[str, object],
*,
observed_at: datetime | None,
immutable: bool = False,
retention_reason: str | None = None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="dist_lists",
module_id="dist_lists",
resource_type=resource_type,
resource_id=resource_id,
category=category,
title=title,
data=data,
observed_at=_aware(observed_at),
immutable_evidence=immutable,
retention_reason=retention_reason,
source_path="/distribution-lists",
)
def _list_filter(query, model, selectors: _Selectors):
if selectors.list_id:
column = model.id if model is DistributionList else model.distribution_list_id
query = query.filter(column == selectors.list_id)
return query
def _limited(query, first, second):
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Distribution Lists DSAR query limit exceeded; narrow selectors."
)
return rows
def _coalesce(*values: object) -> str | None | object:
normalized = {_text(value) for value in values if _text(value)}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _coalesce_email(*values: object) -> str | None | object:
normalized = {
_normalized_email(value) for value in values if _normalized_email(value)
}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _normalized_email(value: object) -> str | None:
text = _text(value)
if not text:
return None
_, address = parseaddr(text)
normalized = (address or text).strip().casefold()
return normalized if "@" in normalized and len(normalized) <= 320 else None
def _normalized_recipient_key(value: object) -> str | None:
text = _text(value)
if text and text.casefold().startswith("email:"):
return _normalized_email(text[6:])
return None
def _text(value: object) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
def _bounded(value: object, limit: int) -> str | None:
text = _text(value)
return text[:limit] if text else None
def _optional(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Distribution Lists DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"distribution_list_subject_scope",
"distribution_list_definition_entry",
"distribution_list_snapshot_recipient",
"distribution_list_actor_attribution",
"distribution_list_revision_actor_attribution",
"distribution_list_snapshot_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "dist_lists" or record.module_id != "dist_lists":
raise ValueError("Distribution Lists DSAR cannot plan a foreign record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Distribution Lists DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "dist_lists" or action.module_id != "dist_lists":
raise ValueError("Distribution Lists DSAR cannot execute a foreign action.")
if not action.action_id.startswith("dist_lists:"):
raise ValueError("Distribution Lists DSAR action identity is invalid.")
__all__ = ["DIST_LISTS_DSAR_CAPABILITY", "DistributionListsDsarProvider"]
+112 -9
View File
@@ -30,6 +30,7 @@ from govoplan_core.core.module_guards import (
persistent_table_uninstall_guard, persistent_table_uninstall_guard,
) )
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
FrontendRoute, FrontendRoute,
@@ -48,6 +49,10 @@ from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_dist_lists.backend.db import models as dist_list_models from govoplan_dist_lists.backend.db import models as dist_list_models
from govoplan_dist_lists.backend.dsar_provider import (
DIST_LISTS_DSAR_CAPABILITY,
DistributionListsDsarProvider,
)
MODULE_ID = "dist_lists" MODULE_ID = "dist_lists"
MODULE_NAME = "Distribution Lists" MODULE_NAME = "Distribution Lists"
@@ -73,9 +78,21 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
PERMISSIONS = ( PERMISSIONS = (
_permission(READ_SCOPE, "View distribution lists", "Read operational distribution lists and expansion previews."), _permission(
_permission(WRITE_SCOPE, "Manage distribution lists", "Create and edit operational distribution lists."), READ_SCOPE,
_permission(ADMIN_SCOPE, "Administer distribution lists", "Configure distribution-list policies and provider integrations."), "View distribution lists",
"Read operational distribution lists and expansion previews.",
),
_permission(
WRITE_SCOPE,
"Manage distribution lists",
"Create and edit operational distribution lists.",
),
_permission(
ADMIN_SCOPE,
"Administer distribution lists",
"Configure distribution-list policies and provider integrations.",
),
) )
ROLE_TEMPLATES = ( ROLE_TEMPLATES = (
@@ -88,6 +105,41 @@ ROLE_TEMPLATES = (
) )
DOCUMENTATION = ( DOCUMENTATION = (
DocumentationTopic(
id=f"{MODULE_ID}.data-subject-requests",
title="Distribution-list data-subject requests",
summary="Export exact recipient projections without exposing the rest of an audience.",
body=(
"Distribution Lists matches exact account, identity, contact, and normalized "
"email selectors against saved recipient entries and frozen expansion snapshots. "
"A list, snapshot, or entry reference may only narrow a verified subject match; "
"it never establishes subject identity by itself. Each frozen snapshot result "
"contains only the matching recipient and minimized channel facts, never other "
"recipients, request parameters, diagnostics, provider evidence, provenance, or "
"expansion hashes. Exact account attribution for list creation, revisions, and "
"snapshot creation is exported separately. Frozen snapshots, saved revisions, "
"and lifecycle attribution remain evidence. Removing a current recipient entry "
"or subject-scoped list requires a new revision and downstream-consumer review."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "addresses", "identity", "campaigns", "audit"),
order=90,
metadata={
"seed": True,
"help_contexts": [
"dist_lists.page",
"dist_lists.preview",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_subject_recipient": "Returns only the exactly matched recipient projection.",
"review_recipient_removal": "Requires a new revision and consumer-impact review.",
"retain_frozen_snapshot": "Preserves immutable expansion and execution evidence.",
},
},
),
DocumentationTopic( DocumentationTopic(
id=f"{MODULE_ID}.boundary", id=f"{MODULE_ID}.boundary",
title="Distribution list boundary", title="Distribution list boundary",
@@ -100,7 +152,17 @@ DOCUMENTATION = (
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner"), audience=("operator", "module_admin", "product_owner"),
related_modules=("addresses", "campaigns", "mail", "postbox", "notifications", "scheduling", "poll", "workflow_engine", "tasks"), related_modules=(
"addresses",
"campaigns",
"mail",
"postbox",
"notifications",
"scheduling",
"poll",
"workflow_engine",
"tasks",
),
metadata={ metadata={
"seed": True, "seed": True,
"help_contexts": [ "help_contexts": [
@@ -163,7 +225,15 @@ DOCUMENTATION = (
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner"), audience=("operator", "module_admin", "product_owner"),
related_modules=("addresses", "campaigns", "dataflow", "idm", "policy", "reporting", "workflow_engine"), related_modules=(
"addresses",
"campaigns",
"dataflow",
"idm",
"policy",
"reporting",
"workflow_engine",
),
metadata={ metadata={
"seed": True, "seed": True,
"help_contexts": [ "help_contexts": [
@@ -199,6 +269,10 @@ def _capability(context: ModuleContext):
return capability(context) return capability(context)
def _dsar_provider(_context: ModuleContext) -> DistributionListsDsarProvider:
return DistributionListsDsarProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return { return {
"distribution_lists": ( "distribution_lists": (
@@ -269,6 +343,10 @@ manifest = ModuleManifest(
name=CAPABILITY_DISTRIBUTION_LIST_WRITER, name=CAPABILITY_DISTRIBUTION_LIST_WRITER,
version=MODULE_VERSION, version=MODULE_VERSION,
), ),
ModuleInterfaceProvider(
name=DIST_LISTS_DSAR_CAPABILITY,
version="0.1.0",
),
), ),
requires_interfaces=( requires_interfaces=(
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
@@ -328,7 +406,10 @@ manifest = ModuleManifest(
label="i18n:govoplan-core.product_area.communication", label="i18n:govoplan-core.product_area.communication",
icon="mail", icon="mail",
description="i18n:govoplan-core.product_area.communication_description", description="i18n:govoplan-core.product_area.communication_description",
surface_ids=("dist_lists.nav.distribution.lists", "dist_lists.route.distribution.lists"), surface_ids=(
"dist_lists.nav.distribution.lists",
"dist_lists.route.distribution.lists",
),
order=40, order=40,
), ),
), ),
@@ -368,6 +449,17 @@ manifest = ModuleManifest(
CAPABILITY_DISTRIBUTION_LIST_SOURCE: _capability, CAPABILITY_DISTRIBUTION_LIST_SOURCE: _capability,
CAPABILITY_DISTRIBUTION_LIST_EXPAND: _capability, CAPABILITY_DISTRIBUTION_LIST_EXPAND: _capability,
CAPABILITY_DISTRIBUTION_LIST_WRITER: _capability, CAPABILITY_DISTRIBUTION_LIST_WRITER: _capability,
DIST_LISTS_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
DIST_LISTS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Distribution Lists data-subject request provider",
summary=(
"Exports exact recipient projections and minimized lifecycle attribution "
"without disclosing other audience members."
),
contract_version="0.1.0",
),
}, },
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
@@ -403,10 +495,21 @@ manifest = ModuleManifest(
maturity="vertical_slice", maturity="vertical_slice",
documentation_ref="docs/DISTRIBUTION_LISTS_ARCHITECTURE.md", documentation_ref="docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",
test_ref="tests/test_service.py", test_ref="tests/test_service.py",
known_limits=("Portal self-service and every AdreMa migration path are not yet implemented.",), known_limits=(
"Portal self-service and every AdreMa migration path are not yet implemented.",
),
supported_authority_modes=("native_authoritative", "external_mirror"), supported_authority_modes=("native_authoritative", "external_mirror"),
owned_concepts=("distribution list", "distribution-list revision", "frozen expansion"), owned_concepts=(
non_owned_concepts=("contact point", "campaign recipient snapshot", "identity", "dataflow output"), "distribution list",
"distribution-list revision",
"frozen expansion",
),
non_owned_concepts=(
"contact point",
"campaign recipient snapshot",
"identity",
"dataflow output",
),
recovery_docs=("docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",), recovery_docs=("docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",),
security_docs=("docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",), security_docs=("docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",),
operations_docs=("README.md",), operations_docs=("README.md",),
+282
View File
@@ -0,0 +1,282 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_dist_lists.backend.db.models import (
DistributionList,
DistributionListEntry,
DistributionListRevision,
DistributionListSnapshot,
)
from govoplan_dist_lists.backend.dsar_provider import (
DIST_LISTS_DSAR_CAPABILITY,
DistributionListsDsarProvider,
)
from govoplan_dist_lists.backend.manifest import manifest
NOW = datetime(2026, 8, 22, 16, 0, tzinfo=UTC)
def _recipient(
*,
key: str,
email: str,
account_id: str | None = None,
identity_id: str | None = None,
) -> dict[str, object]:
return {
"recipient_key": key,
"display_name": f"Recipient {email}",
"status": "usable",
"channels": [
{
"channel": "email",
"target": email,
"target_key": f"email:{email.casefold()}",
"status": "usable",
"contact_point_id": f"contact-point-{key}",
"decision_provenance": {"secret": "do-not-export-provenance"},
}
],
"account_id": account_id,
"identity_id": identity_id,
"contact_id": None,
"source_entry_ids": ["entry-1"],
"attributes": {"secret": "do-not-export-attributes"},
"provenance": {"secret": "do-not-export-recipient-provenance"},
}
class DistributionListsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = DistributionListsDsarProvider()
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
distribution_list = DistributionList(
id="list-1",
tenant_id="tenant-1",
scope_type="user",
scope_id="account-1",
name="Sensitive list name do not export",
description="Sensitive description do not export",
status="active",
current_revision_id="revision-1",
current_revision=1,
resource_revision=1,
created_by_account_id="account-1",
updated_by_account_id="account-1",
metadata_={"secret": "list-metadata-do-not-export"},
created_at=NOW,
updated_at=NOW,
)
revision = DistributionListRevision(
id="revision-1",
tenant_id="tenant-1",
distribution_list_id="list-1",
revision=1,
definition_kind="static",
definition_hash="definition-hash-do-not-export",
parameter_schema=[{"secret": "parameter-do-not-export"}],
constraints={"secret": "constraint-do-not-export"},
source_fingerprints=[{"secret": "fingerprint-do-not-export"}],
created_by_account_id="account-1",
created_at=NOW,
updated_at=NOW,
)
entry = DistributionListEntry(
id="entry-1",
tenant_id="tenant-1",
revision_id="revision-1",
entry_key="subject-email",
kind="raw_email",
mode="include",
source_provider="local",
source_resource_type="email",
source_resource_id="Subject@Example.test",
source_revision="source-revision-do-not-export",
source_fingerprint="source-fingerprint-do-not-export",
source_label="source-label-do-not-export",
source_metadata={"secret": "source-metadata-do-not-export"},
label="entry-label-do-not-export",
purpose="service_notice",
requested_channels=["email"],
order_index=0,
configuration={"secret": "configuration-do-not-export"},
created_at=NOW,
updated_at=NOW,
)
snapshot = DistributionListSnapshot(
id="snapshot-1",
tenant_id="tenant-1",
distribution_list_id="list-1",
revision_id="revision-1",
revision_number=1,
idempotency_key="idempotency-key-do-not-export",
request_={"secret": "request-do-not-export"},
expansion_hash="expansion-hash-do-not-export",
effective_at=NOW,
recipient_count=2,
excluded_count=0,
recipients=[
_recipient(
key="identity-1",
email="subject@example.test",
account_id="account-1",
identity_id="identity-1",
),
_recipient(
key="identity-other",
email="other-person-do-not-export@example.test",
account_id="account-other-do-not-export",
identity_id="identity-other-do-not-export",
),
],
excluded=[],
diagnostics=[{"secret": "diagnostics-do-not-export"}],
provider_evidence=[{"secret": "provider-evidence-do-not-export"}],
stale=False,
truncated=False,
created_by_account_id="account-1",
provenance={"secret": "snapshot-provenance-do-not-export"},
created_at=NOW,
updated_at=NOW,
)
other_tenant = DistributionList(
id="list-other-tenant",
tenant_id="tenant-2",
scope_type="user",
scope_id="account-1",
name="Other tenant do not export",
status="active",
current_revision_id="revision-other",
current_revision=1,
resource_revision=1,
)
self.session.add_all(
(distribution_list, revision, entry, snapshot, other_tenant)
)
def test_search_exports_only_exact_subject_projection(self) -> None:
self.assertIsInstance(self.provider, DsarProvider)
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
identity_id="identity-1",
email=" SUBJECT@example.test ",
),
)
self.assertEqual(
{
"distribution_list_subject_scope",
"distribution_list_definition_entry",
"distribution_list_snapshot_recipient",
"distribution_list_actor_attribution",
"distribution_list_revision_actor_attribution",
"distribution_list_snapshot_actor_attribution",
},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("subject@example.test", exported.casefold())
for excluded in (
"other-person-do-not-export@example.test",
"account-other-do-not-export",
"identity-other-do-not-export",
"list-metadata-do-not-export",
"definition-hash-do-not-export",
"parameter-do-not-export",
"constraint-do-not-export",
"fingerprint-do-not-export",
"source-revision-do-not-export",
"source-fingerprint-do-not-export",
"source-label-do-not-export",
"source-metadata-do-not-export",
"configuration-do-not-export",
"idempotency-key-do-not-export",
"request-do-not-export",
"expansion-hash-do-not-export",
"diagnostics-do-not-export",
"provider-evidence-do-not-export",
"snapshot-provenance-do-not-export",
"do-not-export-provenance",
"do-not-export-attributes",
):
self.assertNotIn(excluded, exported)
def test_resource_references_only_narrow_verified_subjects(self) -> None:
self.assertEqual(
(),
self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"dist_lists.snapshot": "snapshot-1"}
),
),
)
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
email="subject@example.test",
external_references={"dist_lists.snapshot": "snapshot-1"},
),
)
self.assertEqual(
["distribution_list_snapshot_recipient"],
[record.resource_type for record in narrowed],
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
email="subject@example.test",
external_references={"dist_lists.email": "other@example.test"},
),
)
self.assertEqual((), conflict)
def test_definition_change_requires_review_and_snapshot_is_retained(self) -> None:
subject = DsarSubjectRef(email="subject@example.test")
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=subject
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
by_type = {action.resource_type: action.kind for action in actions}
self.assertEqual("manual_review", by_type["distribution_list_definition_entry"])
self.assertEqual("retain", by_type["distribution_list_snapshot_recipient"])
def test_manifest_registers_provider_and_documentation(self) -> None:
self.assertIn(DIST_LISTS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
"dist_lists.data-subject-requests",
{topic.id for topic in manifest.documentation},
)
if __name__ == "__main__":
unittest.main()