feat: add governed DSAR workflow

This commit is contained in:
2026-08-07 14:53:07 +02:00
parent eb0c01c5d2
commit e6457b3f6b
19 changed files with 2239 additions and 2 deletions
+16
View File
@@ -10,6 +10,7 @@ CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext"
CAPABILITY_CAMPAIGNS_ACCESS = "campaigns.access"
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT = "campaigns.policyContext"
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks"
CAPABILITY_CAMPAIGNS_SCHEDULES = "campaigns.schedules"
CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention"
@@ -105,6 +106,21 @@ class CampaignDeliveryTaskProvider(Protocol):
...
@runtime_checkable
class CampaignScheduleProvider(Protocol):
"""Durable boundary for preparing due recurring Campaign drafts."""
def dispatch_due(
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
@runtime_checkable
class CampaignRetentionProvider(Protocol):
def apply_retention(
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
DSAR_CAPABILITY_PREFIX = "privacy.dsar."
DsarRequestKind = Literal["access", "erasure", "access_and_erasure"]
DsarActionKind = Literal[
"delete",
"anonymize",
"revoke",
"detach",
"retain",
"manual_review",
]
@dataclass(frozen=True, slots=True)
class DsarSubjectRef:
account_id: str | None = None
identity_id: str | None = None
membership_id: str | None = None
email: str | None = None
external_references: Mapping[str, str] = field(default_factory=dict)
def has_selector(self) -> bool:
return bool(
self.account_id
or self.identity_id
or self.membership_id
or self.email
or self.external_references
)
def to_dict(self) -> dict[str, object]:
return {
"account_id": self.account_id,
"identity_id": self.identity_id,
"membership_id": self.membership_id,
"email": self.email,
"external_references": dict(self.external_references),
}
@dataclass(frozen=True, slots=True)
class DsarRecordRef:
provider_id: str
module_id: str
resource_type: str
resource_id: str
category: str
title: str
data: Mapping[str, object] = field(default_factory=dict)
observed_at: datetime | None = None
immutable_evidence: bool = False
retention_reason: str | None = None
source_path: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"provider_id": self.provider_id,
"module_id": self.module_id,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"category": self.category,
"title": self.title,
"data": dict(self.data),
"observed_at": self.observed_at.isoformat() if self.observed_at else None,
"immutable_evidence": self.immutable_evidence,
"retention_reason": self.retention_reason,
"source_path": self.source_path,
}
@dataclass(frozen=True, slots=True)
class DsarErasureActionRef:
action_id: str
provider_id: str
module_id: str
kind: DsarActionKind
resource_type: str
resource_id: str
title: str
rationale: str
executable: bool
irreversible: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"action_id": self.action_id,
"provider_id": self.provider_id,
"module_id": self.module_id,
"kind": self.kind,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"title": self.title,
"rationale": self.rationale,
"executable": self.executable,
"irreversible": self.irreversible,
"metadata": dict(self.metadata),
}
@dataclass(frozen=True, slots=True)
class DsarExecutionResultRef:
action_id: str
status: Literal["executed", "unchanged", "failed", "blocked"]
summary: str
evidence: Mapping[str, object] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"action_id": self.action_id,
"status": self.status,
"summary": self.summary,
"evidence": dict(self.evidence),
}
@runtime_checkable
class DsarProvider(Protocol):
provider_id: str
module_id: str
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]: ...
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]: ...
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]: ...
def dsar_capability_name(module_id: str) -> str:
normalized = module_id.strip().casefold()
if not normalized or not normalized.replace("_", "").isalnum():
raise ValueError("DSAR module id must be an identifier.")
return f"{DSAR_CAPABILITY_PREFIX}{normalized}"
def dsar_provider_names(registry: object | None) -> tuple[str, ...]:
if registry is None or not hasattr(registry, "capability_names"):
return ()
return tuple(
name
for name in registry.capability_names()
if name.startswith(DSAR_CAPABILITY_PREFIX)
)
def dsar_provider(
registry: object,
capability_name: str,
) -> DsarProvider:
provider = registry.require_capability(capability_name)
if not isinstance(provider, DsarProvider):
raise TypeError(f"{capability_name} does not implement DsarProvider")
return provider
__all__ = [
"DSAR_CAPABILITY_PREFIX",
"DsarActionKind",
"DsarErasureActionRef",
"DsarExecutionResultRef",
"DsarProvider",
"DsarRecordRef",
"DsarRequestKind",
"DsarSubjectRef",
"dsar_capability_name",
"dsar_provider",
"dsar_provider_names",
]
+57
View File
@@ -9,6 +9,7 @@ from govoplan_core.core.access import ResourceAccessExplanationProvider
CAPABILITY_FILES_ACCESS = "files.access"
CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store"
CAPABILITY_FILES_POSTBOX_REFERENCES = "files.postbox_references"
@dataclass(frozen=True, slots=True)
@@ -34,6 +35,30 @@ class ManagedArtifactRef:
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxFileReferenceRequest:
reference_type: str
reference_id: str
postbox_id: str
message_id: str
@dataclass(frozen=True, slots=True)
class PostboxFileReferenceRef:
reference_type: str
reference_id: str
available: bool
reason_code: str
file_asset_id: str | None = None
file_version_id: str | None = None
filename: str | None = None
content_type: str | None = None
size_bytes: int | None = None
sha256: str | None = None
download_path: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class FileAccessProvider(ResourceAccessExplanationProvider, Protocol):
"""Resource-level access explanation provider for Files-owned resources."""
@@ -50,3 +75,35 @@ class ManagedArtifactStore(Protocol):
*,
request: ManagedArtifactWriteRequest,
) -> ManagedArtifactRef: ...
@runtime_checkable
class PostboxFileReferenceProvider(Protocol):
"""Resolve Files-owned references after Postbox and Files authorization."""
def resolve_postbox_references(
self,
session: object,
principal: object,
*,
tenant_id: str,
requests: tuple[PostboxFileReferenceRequest, ...],
) -> tuple[PostboxFileReferenceRef, ...]: ...
def postbox_file_reference_provider(
registry: object | None,
) -> PostboxFileReferenceProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_FILES_POSTBOX_REFERENCES)
):
return None
provider = registry.require_capability(CAPABILITY_FILES_POSTBOX_REFERENCES)
if not isinstance(provider, PostboxFileReferenceProvider):
raise TypeError(
"files.postbox_references provider does not implement "
"PostboxFileReferenceProvider"
)
return provider
+52
View File
@@ -9,6 +9,7 @@ from typing import Protocol, runtime_checkable
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
CAPABILITY_MAIL_BOUNCE_PROCESSING = "mail.bounce_processing"
CAPABILITY_MAIL_POSTBOX_BRIDGE = "mail.postbox_bridge"
@dataclass(frozen=True, slots=True)
@@ -81,6 +82,28 @@ class MailBounceObservationRef:
evidence: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class MailPostboxBridgeRequest:
tenant_id: str
target: object
profile_id: str
folder: str
uid: str
uidvalidity: str
raw_message: bytes
classification: str = "internal"
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class MailPostboxBridgeResult:
postbox_id: str
message_id: str
delivery_id: str
duplicate: bool
source_digest: str
@runtime_checkable
class MailBounceProcessingProvider(Protocol):
"""Mail-owned DSN ingestion and durable correlation boundary."""
@@ -116,6 +139,17 @@ class MailBounceProcessingProvider(Protocol):
...
@runtime_checkable
class MailPostboxBridgeProvider(Protocol):
"""Translate one immutable Mail observation into Postbox delivery."""
def bridge_message(
self,
session: object,
request: MailPostboxBridgeRequest,
) -> MailPostboxBridgeResult: ...
def notification_mail_delivery_provider(
registry: object | None,
) -> NotificationMailDeliveryProvider | None:
@@ -150,3 +184,21 @@ def mail_bounce_processing_provider(
"MailBounceProcessingProvider"
)
return provider
def mail_postbox_bridge_provider(
registry: object | None,
) -> MailPostboxBridgeProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_MAIL_POSTBOX_BRIDGE)
):
return None
provider = registry.require_capability(CAPABILITY_MAIL_POSTBOX_BRIDGE)
if not isinstance(provider, MailPostboxBridgeProvider):
raise TypeError(
"mail.postbox_bridge provider does not implement "
"MailPostboxBridgeProvider"
)
return provider
+38
View File
@@ -15,6 +15,7 @@ CAPABILITY_POSTBOX_MESSAGES = "postbox.messages"
CAPABILITY_POSTBOX_DELIVERY = "postbox.delivery"
CAPABILITY_POSTBOX_EVIDENCE = "postbox.evidence"
CAPABILITY_POSTBOX_ROUTING = "postbox.routing"
CAPABILITY_POSTBOX_PORTAL = "postbox.portal_projection"
PostboxAction = Literal[
"discover",
@@ -323,6 +324,14 @@ class PostboxDeliveryResult:
evidence: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxPortalEntryRef:
postbox: PostboxDirectoryEntryRef
unread_count: int = 0
latest_message_at: datetime | None = None
route_path: str = "/postbox"
class PostboxDeliveryRejected(RuntimeError):
"""A delivery was rejected before the provider accepted any effect."""
@@ -495,6 +504,20 @@ class PostboxRoutingProvider(Protocol):
...
@runtime_checkable
class PostboxPortalProjectionProvider(Protocol):
"""Project portal-enabled Postboxes without transferring access ownership."""
def list_portal_entries(
self,
session: object,
principal: object,
*,
tenant_id: str,
limit: int = 100,
) -> Sequence[PostboxPortalEntryRef]: ...
def _postbox_provider(
registry: object | None,
*,
@@ -573,3 +596,18 @@ def postbox_routing_provider(
provider_type=PostboxRoutingProvider,
)
return provider if isinstance(provider, PostboxRoutingProvider) else None
def postbox_portal_projection_provider(
registry: object | None,
) -> PostboxPortalProjectionProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_PORTAL,
provider_type=PostboxPortalProjectionProvider,
)
return (
provider
if isinstance(provider, PostboxPortalProjectionProvider)
else None
)
+38
View File
@@ -8,6 +8,7 @@ from typing import Literal, Protocol, runtime_checkable
CAPABILITY_TEMPLATE_CATALOG = "templates.catalog"
CAPABILITY_TEMPLATE_RENDERER = "templates.renderer"
CAPABILITY_TEMPLATE_CONTENT_LIBRARY = "templates.content_library"
TemplateType = Literal[
"label",
@@ -17,6 +18,7 @@ TemplateType = Literal[
"form_letter",
"list_layout",
"email",
"content_fragment",
"generic",
]
TemplateOutputFormat = Literal["html", "text"]
@@ -79,6 +81,10 @@ class TemplateRevisionRef:
locale: str
required_fields: tuple[TemplateFieldRequirement, ...]
output_profiles: tuple[TemplateOutputProfile, ...]
content_text: str | None = None
content_html: str | None = None
layout: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
published_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@@ -166,6 +172,22 @@ class TemplateRenderResult:
payload: bytes | None = None
@dataclass(frozen=True, slots=True)
class TemplateContentDraftRequest:
"""Provider-neutral request for a reusable text/HTML content draft."""
name: str
template_type: TemplateType
usages: tuple[str, ...]
content_text: str | None = None
content_html: str | None = None
description: str | None = None
locale: str = "de"
scope_type: Literal["tenant", "group", "user"] = "tenant"
scope_id: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class TemplateCatalogProvider(Protocol):
def list_templates(
@@ -213,13 +235,29 @@ class TemplateRendererProvider(Protocol):
) -> TemplateRenderResult: ...
@runtime_checkable
class TemplateContentLibraryProvider(Protocol):
"""Create reusable content drafts while Templates retains ownership."""
def create_content_draft(
self,
session: object,
principal: object,
*,
request: TemplateContentDraftRequest,
) -> TemplateRef: ...
__all__ = [
"CAPABILITY_TEMPLATE_CATALOG",
"CAPABILITY_TEMPLATE_CONTENT_LIBRARY",
"CAPABILITY_TEMPLATE_RENDERER",
"TemplateArtifactRef",
"TemplateCatalogProvider",
"TemplateCompatibility",
"TemplateCompatibilityError",
"TemplateContentDraftRequest",
"TemplateContentLibraryProvider",
"TemplateContractError",
"TemplateFieldRequirement",
"TemplateNotFoundError",