feat: add governed DSAR workflow
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user