feat: add governed DSAR workflow
This commit is contained in:
@@ -18,7 +18,9 @@ from celery.signals import (
|
||||
|
||||
from govoplan_core.core.campaigns import (
|
||||
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS,
|
||||
CAPABILITY_CAMPAIGNS_SCHEDULES,
|
||||
CampaignDeliveryTaskProvider,
|
||||
CampaignScheduleProvider,
|
||||
)
|
||||
from govoplan_core.core.calendar import (
|
||||
CAPABILITY_CALENDAR_OUTBOX,
|
||||
@@ -100,6 +102,7 @@ celery.conf.update(
|
||||
task_routes={
|
||||
"govoplan.campaigns.send_email": {"queue": "send_email"},
|
||||
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
|
||||
"govoplan.campaigns.dispatch_schedules": {"queue": "default"},
|
||||
"govoplan.notifications.deliver": {"queue": "notifications"},
|
||||
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
||||
"govoplan.mail.dispatch_outbox": {"queue": "mail"},
|
||||
@@ -132,6 +135,11 @@ celery.conf.update(
|
||||
"schedule": 60.0,
|
||||
"args": (None, 100),
|
||||
},
|
||||
"campaign-schedules-every-minute": {
|
||||
"task": "govoplan.campaigns.dispatch_schedules",
|
||||
"schedule": 60.0,
|
||||
"args": (None, 50),
|
||||
},
|
||||
"mail-outbox-every-five-seconds": {
|
||||
"task": "govoplan.mail.dispatch_outbox",
|
||||
"schedule": 5.0,
|
||||
@@ -555,6 +563,18 @@ def _campaign_delivery_tasks(
|
||||
return capability
|
||||
|
||||
|
||||
def _campaign_schedules(
|
||||
registry: PlatformRegistry | None = None,
|
||||
) -> CampaignScheduleProvider | None:
|
||||
registry = registry or _platform_registry()
|
||||
if not registry.has_capability(CAPABILITY_CAMPAIGNS_SCHEDULES):
|
||||
return None
|
||||
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_SCHEDULES)
|
||||
if not isinstance(capability, CampaignScheduleProvider):
|
||||
raise RuntimeError("Campaign schedule capability is invalid")
|
||||
return capability
|
||||
|
||||
|
||||
def _notification_dispatch(
|
||||
registry: PlatformRegistry | None = None,
|
||||
) -> NotificationDispatchProvider:
|
||||
@@ -699,6 +719,52 @@ def _idm_assignment_lifecycle(
|
||||
return capability
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="govoplan.campaigns.dispatch_schedules",
|
||||
bind=True,
|
||||
max_retries=0,
|
||||
)
|
||||
def dispatch_campaign_schedules(
|
||||
self,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 50,
|
||||
):
|
||||
"""Prepare due campaign drafts; delivery always remains a separate action."""
|
||||
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().SessionLocal() as session:
|
||||
registry = _platform_registry()
|
||||
defaults = {
|
||||
"selected": 0,
|
||||
"prepared": 0,
|
||||
"failed": 0,
|
||||
"completed": 0,
|
||||
"coalesced": 0,
|
||||
"campaign_ids": [],
|
||||
"operator_actions": [],
|
||||
}
|
||||
if not registry.has_capability(CAPABILITY_CAMPAIGNS_SCHEDULES):
|
||||
return defaults
|
||||
result = _run_tenant_worker_batches(
|
||||
registry,
|
||||
session,
|
||||
capability_name=CAPABILITY_CAMPAIGNS_SCHEDULES,
|
||||
tenant_id=tenant_id,
|
||||
operation=lambda effective_tenant_id: _campaign_schedules(
|
||||
registry
|
||||
).dispatch_due( # type: ignore[union-attr]
|
||||
session,
|
||||
tenant_id=effective_tenant_id,
|
||||
limit=limit,
|
||||
),
|
||||
defaults=defaults,
|
||||
work_state="new",
|
||||
)
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
|
||||
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
|
||||
def send_email(self, job_id: str):
|
||||
"""Send one explicitly queued campaign job.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,753 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from govoplan_core.core.concurrency import RevisionConflictError, strong_resource_etag
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarProvider,
|
||||
DsarRecordRef,
|
||||
DsarRequestKind,
|
||||
DsarSubjectRef,
|
||||
dsar_provider_names,
|
||||
)
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
MAX_RECORDS_PER_PROVIDER = 10_000
|
||||
MAX_PROVIDER_RESULT_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
class DataSubjectRequest(Base, TimestampMixin):
|
||||
__tablename__ = "core_data_subject_requests"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_core_data_subject_requests_tenant_status",
|
||||
"tenant_id",
|
||||
"status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
reference: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
request_kind: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30), default="draft", nullable=False, index=True
|
||||
)
|
||||
subject: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
purpose: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
legal_basis: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
due_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
requested_by_account_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
search_result: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
erasure_plan: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
execution_result: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
coverage: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
evidence_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
"data_subject_request",
|
||||
self.id,
|
||||
self.resource_revision,
|
||||
)
|
||||
|
||||
|
||||
def create_data_subject_request(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
reference: str,
|
||||
request_kind: DsarRequestKind,
|
||||
subject: DsarSubjectRef,
|
||||
purpose: str,
|
||||
legal_basis: str | None,
|
||||
due_at: datetime | None,
|
||||
requested_by_account_id: str,
|
||||
notes: str | None = None,
|
||||
) -> DataSubjectRequest:
|
||||
if not subject.has_selector():
|
||||
raise ValueError("At least one data-subject selector is required.")
|
||||
row = DataSubjectRequest(
|
||||
tenant_id=tenant_id,
|
||||
reference=reference.strip(),
|
||||
request_kind=request_kind,
|
||||
subject=subject.to_dict(),
|
||||
purpose=purpose.strip(),
|
||||
legal_basis=(legal_basis or "").strip() or None,
|
||||
due_at=due_at,
|
||||
requested_by_account_id=requested_by_account_id,
|
||||
notes=(notes or "").strip() or None,
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
row.evidence_sha256 = _evidence_digest(row)
|
||||
return row
|
||||
|
||||
|
||||
def get_data_subject_request(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
for_update: bool = False,
|
||||
) -> DataSubjectRequest:
|
||||
query = session.query(DataSubjectRequest).filter(
|
||||
DataSubjectRequest.id == request_id,
|
||||
DataSubjectRequest.tenant_id == tenant_id,
|
||||
)
|
||||
if for_update:
|
||||
query = query.with_for_update()
|
||||
row = query.one_or_none()
|
||||
if row is None or row.tenant_id != tenant_id:
|
||||
raise LookupError("Data-subject request not found.")
|
||||
return row
|
||||
|
||||
|
||||
def list_data_subject_requests(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
limit: int = 200,
|
||||
) -> tuple[DataSubjectRequest, ...]:
|
||||
return tuple(
|
||||
session.query(DataSubjectRequest)
|
||||
.filter(DataSubjectRequest.tenant_id == tenant_id)
|
||||
.order_by(
|
||||
DataSubjectRequest.created_at.desc(),
|
||||
DataSubjectRequest.id.desc(),
|
||||
)
|
||||
.limit(max(1, min(limit, 500)))
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def search_data_subject_request(
|
||||
session: Session,
|
||||
*,
|
||||
registry: object,
|
||||
row: DataSubjectRequest,
|
||||
expected_revision: int,
|
||||
) -> DataSubjectRequest:
|
||||
_assert_revision(row, expected_revision)
|
||||
subject = _subject(row.subject)
|
||||
records: list[dict[str, object]] = []
|
||||
provider_runs: list[dict[str, object]] = []
|
||||
providers, discovery = _providers(
|
||||
registry,
|
||||
session,
|
||||
tenant_id=row.tenant_id,
|
||||
)
|
||||
for capability_name, provider in providers:
|
||||
started_at = _now()
|
||||
try:
|
||||
with session.begin_nested():
|
||||
provider_records = tuple(
|
||||
provider.search_subject(
|
||||
session,
|
||||
tenant_id=row.tenant_id,
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
_validate_records(provider, provider_records)
|
||||
if len(provider_records) > MAX_RECORDS_PER_PROVIDER:
|
||||
raise ValueError("DSAR provider result exceeds the record limit.")
|
||||
encoded = _json_bytes([item.to_dict() for item in provider_records])
|
||||
if len(encoded) > MAX_PROVIDER_RESULT_BYTES:
|
||||
raise ValueError("DSAR provider result exceeds the payload limit.")
|
||||
records.extend(item.to_dict() for item in provider_records)
|
||||
provider_runs.append(
|
||||
{
|
||||
"capability": capability_name,
|
||||
"provider_id": provider.provider_id,
|
||||
"module_id": provider.module_id,
|
||||
"status": "complete",
|
||||
"record_count": len(provider_records),
|
||||
"started_at": started_at,
|
||||
"completed_at": _now(),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
provider_runs.append(
|
||||
{
|
||||
"capability": capability_name,
|
||||
"provider_id": getattr(provider, "provider_id", capability_name),
|
||||
"module_id": getattr(provider, "module_id", "unknown"),
|
||||
"status": "failed",
|
||||
"record_count": 0,
|
||||
"error": _safe_error(exc),
|
||||
"started_at": started_at,
|
||||
"completed_at": _now(),
|
||||
}
|
||||
)
|
||||
failures = sum(1 for item in provider_runs if item["status"] == "failed")
|
||||
row.search_result = {
|
||||
"schema": "govoplan.dsars.search.v1",
|
||||
"searched_at": _now(),
|
||||
"records": records,
|
||||
"provider_runs": provider_runs,
|
||||
"record_count": len(records),
|
||||
}
|
||||
row.coverage = discovery
|
||||
row.erasure_plan = {}
|
||||
row.execution_result = {}
|
||||
row.status = "search_partial" if failures else "searched"
|
||||
row.completed_at = None
|
||||
_advance(row)
|
||||
return row
|
||||
|
||||
|
||||
def plan_data_subject_erasure(
|
||||
session: Session,
|
||||
*,
|
||||
registry: object,
|
||||
row: DataSubjectRequest,
|
||||
expected_revision: int,
|
||||
) -> DataSubjectRequest:
|
||||
_assert_revision(row, expected_revision)
|
||||
if not row.search_result.get("searched_at"):
|
||||
raise ValueError("Run the data-subject search before planning erasure.")
|
||||
if row.request_kind == "access":
|
||||
raise ValueError("This request does not include erasure.")
|
||||
subject = _subject(row.subject)
|
||||
records = tuple(_record(item) for item in row.search_result.get("records", []))
|
||||
by_provider: dict[str, list[DsarRecordRef]] = defaultdict(list)
|
||||
for record in records:
|
||||
by_provider[record.provider_id].append(record)
|
||||
providers, discovery = _providers(
|
||||
registry,
|
||||
session,
|
||||
tenant_id=row.tenant_id,
|
||||
)
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
provider_runs: list[dict[str, object]] = []
|
||||
for capability_name, provider in providers:
|
||||
started_at = _now()
|
||||
try:
|
||||
with session.begin_nested():
|
||||
proposed = tuple(
|
||||
provider.plan_erasure(
|
||||
session,
|
||||
tenant_id=row.tenant_id,
|
||||
subject=subject,
|
||||
records=tuple(by_provider.get(provider.provider_id, ())),
|
||||
)
|
||||
)
|
||||
_validate_actions(provider, proposed)
|
||||
actions.extend(proposed)
|
||||
provider_runs.append(
|
||||
{
|
||||
"capability": capability_name,
|
||||
"provider_id": provider.provider_id,
|
||||
"module_id": provider.module_id,
|
||||
"status": "complete",
|
||||
"action_count": len(proposed),
|
||||
"started_at": started_at,
|
||||
"completed_at": _now(),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
provider_runs.append(
|
||||
{
|
||||
"capability": capability_name,
|
||||
"provider_id": getattr(provider, "provider_id", capability_name),
|
||||
"module_id": getattr(provider, "module_id", "unknown"),
|
||||
"status": "failed",
|
||||
"action_count": 0,
|
||||
"error": _safe_error(exc),
|
||||
"started_at": started_at,
|
||||
"completed_at": _now(),
|
||||
}
|
||||
)
|
||||
existing_resources = {
|
||||
(item.provider_id, item.resource_type, item.resource_id) for item in actions
|
||||
}
|
||||
for record in records:
|
||||
key = (record.provider_id, record.resource_type, record.resource_id)
|
||||
if not record.immutable_evidence or key in existing_resources:
|
||||
continue
|
||||
digest = hashlib.sha256("\0".join(key).encode("utf-8")).hexdigest()[:24]
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=f"retain:{digest}",
|
||||
provider_id=record.provider_id,
|
||||
module_id=record.module_id,
|
||||
kind="retain",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"Retain {record.title}",
|
||||
rationale=(
|
||||
record.retention_reason
|
||||
or "Immutable institutional evidence must be retained."
|
||||
),
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
duplicate_ids = _duplicates(item.action_id for item in actions)
|
||||
if duplicate_ids:
|
||||
raise ValueError(
|
||||
f"DSAR providers returned duplicate action ids: {', '.join(duplicate_ids)}"
|
||||
)
|
||||
failures = sum(1 for item in provider_runs if item["status"] == "failed")
|
||||
row.erasure_plan = {
|
||||
"schema": "govoplan.dsars.erasure-plan.v1",
|
||||
"planned_at": _now(),
|
||||
"actions": [item.to_dict() for item in actions],
|
||||
"provider_runs": provider_runs,
|
||||
"executable_count": sum(1 for item in actions if item.executable),
|
||||
"retained_count": sum(1 for item in actions if item.kind == "retain"),
|
||||
}
|
||||
row.coverage = {**discovery, "plan_provider_runs": provider_runs}
|
||||
row.execution_result = {}
|
||||
row.status = "plan_partial" if failures else "plan_ready"
|
||||
row.completed_at = None
|
||||
_advance(row)
|
||||
return row
|
||||
|
||||
|
||||
def execute_data_subject_erasure(
|
||||
session: Session,
|
||||
*,
|
||||
registry: object,
|
||||
row: DataSubjectRequest,
|
||||
expected_revision: int,
|
||||
action_ids: Sequence[str],
|
||||
) -> DataSubjectRequest:
|
||||
_assert_revision(row, expected_revision)
|
||||
raw_actions = row.erasure_plan.get("actions")
|
||||
if not isinstance(raw_actions, list):
|
||||
raise ValueError("Create an erasure plan before execution.")
|
||||
actions_by_id = {
|
||||
action.action_id: action for action in (_action(item) for item in raw_actions)
|
||||
}
|
||||
selected_ids = tuple(dict.fromkeys(str(item) for item in action_ids if str(item)))
|
||||
if not selected_ids:
|
||||
raise ValueError("Select at least one executable erasure action.")
|
||||
missing = [item for item in selected_ids if item not in actions_by_id]
|
||||
if missing:
|
||||
raise ValueError("The erasure plan changed; refresh before executing it.")
|
||||
selected = tuple(actions_by_id[item] for item in selected_ids)
|
||||
blocked = [item.action_id for item in selected if not item.executable]
|
||||
if blocked:
|
||||
raise ValueError("Retained or review-only actions cannot be executed.")
|
||||
|
||||
providers, _discovery = _providers(
|
||||
registry,
|
||||
session,
|
||||
tenant_id=row.tenant_id,
|
||||
)
|
||||
providers_by_id = {provider.provider_id: provider for _name, provider in providers}
|
||||
grouped: dict[str, list[DsarErasureActionRef]] = defaultdict(list)
|
||||
for action in selected:
|
||||
grouped[action.provider_id].append(action)
|
||||
previous = {
|
||||
str(item.get("action_id")): item
|
||||
for item in row.execution_result.get("results", [])
|
||||
if isinstance(item, Mapping)
|
||||
and item.get("status") in {"executed", "unchanged"}
|
||||
}
|
||||
results: list[dict[str, object]] = list(previous.values())
|
||||
for provider_id, provider_actions in grouped.items():
|
||||
provider = providers_by_id.get(provider_id)
|
||||
if provider is None:
|
||||
results.extend(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary="The owning DSAR provider is not currently available.",
|
||||
).to_dict()
|
||||
for action in provider_actions
|
||||
)
|
||||
continue
|
||||
pending = tuple(
|
||||
action for action in provider_actions if action.action_id not in previous
|
||||
)
|
||||
if not pending:
|
||||
continue
|
||||
try:
|
||||
with session.begin_nested():
|
||||
executed = tuple(
|
||||
provider.execute_erasure(
|
||||
session,
|
||||
tenant_id=row.tenant_id,
|
||||
subject=_subject(row.subject),
|
||||
actions=pending,
|
||||
request_id=row.id,
|
||||
)
|
||||
)
|
||||
_validate_execution_results(pending, executed)
|
||||
results.extend(item.to_dict() for item in executed)
|
||||
except Exception as exc:
|
||||
results.extend(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="failed",
|
||||
summary=_safe_error(exc),
|
||||
).to_dict()
|
||||
for action in pending
|
||||
)
|
||||
result_by_id = {str(item["action_id"]): item for item in results}
|
||||
all_executable = {
|
||||
action.action_id for action in actions_by_id.values() if action.executable
|
||||
}
|
||||
successful = {
|
||||
action_id
|
||||
for action_id, item in result_by_id.items()
|
||||
if item.get("status") in {"executed", "unchanged"}
|
||||
}
|
||||
failed = {
|
||||
action_id
|
||||
for action_id, item in result_by_id.items()
|
||||
if item.get("status") in {"failed", "blocked"}
|
||||
}
|
||||
row.execution_result = {
|
||||
"schema": "govoplan.dsars.execution.v1",
|
||||
"executed_at": _now(),
|
||||
"results": list(result_by_id.values()),
|
||||
"successful_count": len(successful),
|
||||
"failed_count": len(failed),
|
||||
}
|
||||
if all_executable.issubset(successful):
|
||||
row.status = "completed"
|
||||
row.completed_at = datetime.now(timezone.utc)
|
||||
elif failed:
|
||||
row.status = "execution_partial"
|
||||
row.completed_at = None
|
||||
else:
|
||||
row.status = "execution_pending"
|
||||
row.completed_at = None
|
||||
_advance(row)
|
||||
return row
|
||||
|
||||
|
||||
def data_subject_export(row: DataSubjectRequest) -> bytes:
|
||||
payload: dict[str, object] = {
|
||||
"schema": "govoplan.dsars.export.v1",
|
||||
"generated_at": _now(),
|
||||
"request": data_subject_request_dict(row, include_subject=True),
|
||||
"search": row.search_result,
|
||||
"erasure_plan": row.erasure_plan,
|
||||
"execution": row.execution_result,
|
||||
"coverage": row.coverage,
|
||||
"limitations": [
|
||||
"Only providers listed as complete contributed data.",
|
||||
"Retained immutable evidence is exported with its stated retention reason.",
|
||||
"An export does not itself erase or alter source data.",
|
||||
],
|
||||
}
|
||||
digest = hashlib.sha256(_json_bytes(payload)).hexdigest()
|
||||
payload["manifest_sha256"] = digest
|
||||
return _json_bytes(payload, pretty=True)
|
||||
|
||||
|
||||
def data_subject_request_dict(
|
||||
row: DataSubjectRequest,
|
||||
*,
|
||||
include_subject: bool,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"tenant_id": row.tenant_id,
|
||||
"reference": row.reference,
|
||||
"request_kind": row.request_kind,
|
||||
"status": row.status,
|
||||
"subject": dict(row.subject) if include_subject else {},
|
||||
"purpose": row.purpose,
|
||||
"legal_basis": row.legal_basis,
|
||||
"due_at": row.due_at.isoformat() if row.due_at else None,
|
||||
"requested_by_account_id": row.requested_by_account_id,
|
||||
"record_count": int(row.search_result.get("record_count", 0)),
|
||||
"executable_action_count": int(
|
||||
row.erasure_plan.get("executable_count", 0)
|
||||
),
|
||||
"coverage": dict(row.coverage),
|
||||
"evidence_sha256": row.evidence_sha256,
|
||||
"resource_revision": row.resource_revision,
|
||||
"etag": row.strong_etag,
|
||||
"created_at": row.created_at.isoformat(),
|
||||
"updated_at": row.updated_at.isoformat(),
|
||||
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
|
||||
"notes": row.notes,
|
||||
}
|
||||
|
||||
|
||||
def _providers(
|
||||
registry: object,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> tuple[tuple[tuple[str, DsarProvider], ...], dict[str, object]]:
|
||||
values: list[tuple[str, DsarProvider]] = []
|
||||
failures: list[dict[str, str]] = []
|
||||
active_modules = _effective_module_ids(registry, session, tenant_id=tenant_id)
|
||||
names = dsar_provider_names(registry)
|
||||
active_names: list[str] = []
|
||||
inactive_names: list[str] = []
|
||||
for name in names:
|
||||
owner = (
|
||||
registry.capability_owner(name)
|
||||
if hasattr(registry, "capability_owner")
|
||||
else None
|
||||
)
|
||||
if owner and owner not in active_modules:
|
||||
inactive_names.append(name)
|
||||
continue
|
||||
active_names.append(name)
|
||||
try:
|
||||
if hasattr(registry, "require_tenant_capability"):
|
||||
candidate = registry.require_tenant_capability(
|
||||
name,
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
work_state="interactive",
|
||||
)
|
||||
else:
|
||||
candidate = registry.require_capability(name)
|
||||
if not isinstance(candidate, DsarProvider):
|
||||
raise TypeError("Capability does not implement DsarProvider.")
|
||||
values.append((name, candidate))
|
||||
except Exception as exc:
|
||||
failures.append({"capability": name, "error": _safe_error(exc)})
|
||||
covered_modules = sorted({provider.module_id for _name, provider in values})
|
||||
return tuple(values), {
|
||||
"provider_capabilities": active_names,
|
||||
"inactive_provider_capabilities": inactive_names,
|
||||
"covered_modules": covered_modules,
|
||||
"modules_without_provider": sorted(set(active_modules) - set(covered_modules)),
|
||||
"provider_discovery_failures": failures,
|
||||
}
|
||||
|
||||
|
||||
def _effective_module_ids(
|
||||
registry: object,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> list[str]:
|
||||
resolver_factory = getattr(registry, "tenant_entitlement_resolver", None)
|
||||
if callable(resolver_factory):
|
||||
state = resolver_factory().resolve(session, tenant_id)
|
||||
return sorted(str(item) for item in state.effective_modules)
|
||||
if hasattr(registry, "manifests"):
|
||||
return sorted(str(manifest.id) for manifest in registry.manifests())
|
||||
return []
|
||||
|
||||
|
||||
def _subject(value: Mapping[str, object]) -> DsarSubjectRef:
|
||||
raw_refs = value.get("external_references")
|
||||
return DsarSubjectRef(
|
||||
account_id=_optional_text(value.get("account_id")),
|
||||
identity_id=_optional_text(value.get("identity_id")),
|
||||
membership_id=_optional_text(value.get("membership_id")),
|
||||
email=_optional_text(value.get("email")),
|
||||
external_references={
|
||||
str(key): str(item)
|
||||
for key, item in (raw_refs.items() if isinstance(raw_refs, Mapping) else ())
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _record(value: object) -> DsarRecordRef:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("Stored DSAR record is invalid.")
|
||||
observed_at = _parse_datetime(value.get("observed_at"))
|
||||
data = value.get("data")
|
||||
return DsarRecordRef(
|
||||
provider_id=str(value.get("provider_id") or ""),
|
||||
module_id=str(value.get("module_id") or ""),
|
||||
resource_type=str(value.get("resource_type") or ""),
|
||||
resource_id=str(value.get("resource_id") or ""),
|
||||
category=str(value.get("category") or ""),
|
||||
title=str(value.get("title") or ""),
|
||||
data=dict(data) if isinstance(data, Mapping) else {},
|
||||
observed_at=observed_at,
|
||||
immutable_evidence=bool(value.get("immutable_evidence")),
|
||||
retention_reason=_optional_text(value.get("retention_reason")),
|
||||
source_path=_optional_text(value.get("source_path")),
|
||||
)
|
||||
|
||||
|
||||
def _action(value: object) -> DsarErasureActionRef:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("Stored DSAR action is invalid.")
|
||||
metadata = value.get("metadata")
|
||||
kind = str(value.get("kind") or "manual_review")
|
||||
if kind not in {
|
||||
"delete",
|
||||
"anonymize",
|
||||
"revoke",
|
||||
"detach",
|
||||
"retain",
|
||||
"manual_review",
|
||||
}:
|
||||
raise ValueError("Stored DSAR action kind is invalid.")
|
||||
return DsarErasureActionRef(
|
||||
action_id=str(value.get("action_id") or ""),
|
||||
provider_id=str(value.get("provider_id") or ""),
|
||||
module_id=str(value.get("module_id") or ""),
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
resource_type=str(value.get("resource_type") or ""),
|
||||
resource_id=str(value.get("resource_id") or ""),
|
||||
title=str(value.get("title") or ""),
|
||||
rationale=str(value.get("rationale") or ""),
|
||||
executable=bool(value.get("executable")),
|
||||
irreversible=bool(value.get("irreversible")),
|
||||
metadata=dict(metadata) if isinstance(metadata, Mapping) else {},
|
||||
)
|
||||
|
||||
|
||||
def _validate_records(
|
||||
provider: DsarProvider,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> None:
|
||||
for record in records:
|
||||
if record.provider_id != provider.provider_id or record.module_id != provider.module_id:
|
||||
raise ValueError("DSAR record ownership does not match its provider.")
|
||||
if not record.resource_type or not record.resource_id or not record.title:
|
||||
raise ValueError("DSAR records require resource identity and title.")
|
||||
|
||||
|
||||
def _validate_actions(
|
||||
provider: DsarProvider,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
) -> None:
|
||||
for action in actions:
|
||||
if action.provider_id != provider.provider_id or action.module_id != provider.module_id:
|
||||
raise ValueError("DSAR action ownership does not match its provider.")
|
||||
if not action.action_id or not action.resource_type or not action.resource_id:
|
||||
raise ValueError("DSAR actions require stable identities.")
|
||||
if action.kind in {"retain", "manual_review"} and action.executable:
|
||||
raise ValueError("Retain and manual-review actions cannot be executable.")
|
||||
|
||||
|
||||
def _validate_execution_results(
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
results: Sequence[DsarExecutionResultRef],
|
||||
) -> None:
|
||||
expected = {action.action_id for action in actions}
|
||||
returned = {result.action_id for result in results}
|
||||
if expected != returned or len(returned) != len(results):
|
||||
raise ValueError("DSAR provider did not return one result per action.")
|
||||
|
||||
|
||||
def _assert_revision(row: DataSubjectRequest, expected_revision: int) -> None:
|
||||
if row.resource_revision != expected_revision:
|
||||
raise RevisionConflictError(
|
||||
resource_type="data_subject_request",
|
||||
resource_id=row.id,
|
||||
current_revision=row.resource_revision,
|
||||
submitted_base_revision=expected_revision,
|
||||
current_etag=row.strong_etag,
|
||||
refresh_path=f"/api/v1/admin/privacy/data-subject-requests/{row.id}",
|
||||
)
|
||||
|
||||
|
||||
def _advance(row: DataSubjectRequest) -> None:
|
||||
row.resource_revision += 1
|
||||
row.evidence_sha256 = _evidence_digest(row)
|
||||
|
||||
|
||||
def _evidence_digest(row: DataSubjectRequest) -> str:
|
||||
return hashlib.sha256(
|
||||
_json_bytes(
|
||||
{
|
||||
"id": row.id,
|
||||
"tenant_id": row.tenant_id,
|
||||
"reference": row.reference,
|
||||
"request_kind": row.request_kind,
|
||||
"status": row.status,
|
||||
"subject": row.subject,
|
||||
"search_result": row.search_result,
|
||||
"erasure_plan": row.erasure_plan,
|
||||
"execution_result": row.execution_result,
|
||||
"coverage": row.coverage,
|
||||
"resource_revision": row.resource_revision,
|
||||
}
|
||||
)
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _duplicates(values: Iterable[str]) -> tuple[str, ...]:
|
||||
seen: set[str] = set()
|
||||
duplicate: set[str] = set()
|
||||
for value in values:
|
||||
if value in seen:
|
||||
duplicate.add(value)
|
||||
seen.add(value)
|
||||
return tuple(sorted(duplicate))
|
||||
|
||||
|
||||
def _json_bytes(value: object, *, pretty: bool = False) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
indent=2 if pretty else None,
|
||||
separators=None if pretty else (",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _safe_error(exc: Exception) -> str:
|
||||
text = " ".join(str(exc).split())
|
||||
return (text or exc.__class__.__name__)[:1000]
|
||||
|
||||
|
||||
def _optional_text(value: object | None) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _parse_datetime(value: object | None) -> datetime | None:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DataSubjectRequest",
|
||||
"create_data_subject_request",
|
||||
"data_subject_export",
|
||||
"data_subject_request_dict",
|
||||
"execute_data_subject_erasure",
|
||||
"get_data_subject_request",
|
||||
"list_data_subject_requests",
|
||||
"plan_data_subject_erasure",
|
||||
"search_data_subject_request",
|
||||
]
|
||||
@@ -10,6 +10,7 @@ from govoplan_core.server.fastapi import create_govoplan_app
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.bootstrap import create_bootstrap_router
|
||||
from govoplan_core.server.credentials import router as credential_router
|
||||
from govoplan_core.server.dsar import router as dsar_router
|
||||
from govoplan_core.server.ownership import router as ownership_router
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
from govoplan_core.server.route_validation import validate_no_route_collisions
|
||||
@@ -72,6 +73,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
|
||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
||||
api_router.include_router(create_bootstrap_router(server_config.settings))
|
||||
api_router.include_router(credential_router)
|
||||
api_router.include_router(dsar_router)
|
||||
api_router.include_router(ownership_router)
|
||||
for router in server_config.post_module_routers:
|
||||
api_router.include_router(router)
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.concurrency import (
|
||||
ConcurrencyError,
|
||||
MissingPreconditionError,
|
||||
RevisionConflictError,
|
||||
assert_revision_precondition,
|
||||
)
|
||||
from govoplan_core.core.dsar import DsarSubjectRef
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
DataSubjectRequest,
|
||||
create_data_subject_request,
|
||||
data_subject_export,
|
||||
data_subject_request_dict,
|
||||
execute_data_subject_erasure,
|
||||
get_data_subject_request,
|
||||
list_data_subject_requests,
|
||||
plan_data_subject_erasure,
|
||||
search_data_subject_request,
|
||||
)
|
||||
|
||||
|
||||
READ_SCOPE = "access:privacy:read"
|
||||
MANAGE_SCOPE = "access:privacy:manage"
|
||||
EXPORT_SCOPE = "access:privacy:export"
|
||||
ERASE_SCOPE = "access:privacy:erase"
|
||||
|
||||
|
||||
class DataSubjectSelectorRequest(BaseModel):
|
||||
account_id: str | None = Field(default=None, max_length=36)
|
||||
identity_id: str | None = Field(default=None, max_length=36)
|
||||
membership_id: str | None = Field(default=None, max_length=36)
|
||||
email: str | None = Field(default=None, max_length=320)
|
||||
external_references: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("external_references")
|
||||
@classmethod
|
||||
def validate_external_references(
|
||||
cls,
|
||||
value: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
if len(value) > 50:
|
||||
raise ValueError("At most 50 external subject references are allowed.")
|
||||
normalized: dict[str, str] = {}
|
||||
for raw_key, raw_value in value.items():
|
||||
key = str(raw_key).strip()
|
||||
item = str(raw_value).strip()
|
||||
if not key or not item:
|
||||
continue
|
||||
if len(key) > 120 or len(item) > 500:
|
||||
raise ValueError(
|
||||
"External subject-reference namespaces are limited to 120 "
|
||||
"characters and values to 500 characters."
|
||||
)
|
||||
normalized[key] = item
|
||||
return normalized
|
||||
|
||||
|
||||
class DataSubjectRequestCreate(BaseModel):
|
||||
reference: str = Field(min_length=1, max_length=120)
|
||||
request_kind: Literal["access", "erasure", "access_and_erasure"]
|
||||
subject: DataSubjectSelectorRequest
|
||||
purpose: str = Field(min_length=1, max_length=1000)
|
||||
legal_basis: str | None = Field(default=None, max_length=1000)
|
||||
due_at: datetime | None = None
|
||||
notes: str | None = Field(default=None, max_length=10_000)
|
||||
|
||||
|
||||
class RevisionMutationRequest(BaseModel):
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class DataSubjectExecutionRequest(RevisionMutationRequest):
|
||||
action_ids: list[str] = Field(min_length=1, max_length=10_000)
|
||||
confirmation: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class DataSubjectRequestResponse(BaseModel):
|
||||
request: dict[str, Any]
|
||||
search: dict[str, Any] = Field(default_factory=dict)
|
||||
erasure_plan: dict[str, Any] = Field(default_factory=dict)
|
||||
execution: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DataSubjectRequestListResponse(BaseModel):
|
||||
items: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/privacy/data-subject-requests",
|
||||
tags=["data-subject-requests"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=DataSubjectRequestListResponse)
|
||||
def list_requests(
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> DataSubjectRequestListResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
rows = list_data_subject_requests(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
limit=limit,
|
||||
)
|
||||
return DataSubjectRequestListResponse(
|
||||
items=[
|
||||
data_subject_request_dict(row, include_subject=True)
|
||||
for row in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=DataSubjectRequestResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_request(
|
||||
payload: DataSubjectRequestCreate,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> DataSubjectRequestResponse:
|
||||
_require(principal, MANAGE_SCOPE)
|
||||
try:
|
||||
row = create_data_subject_request(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
reference=payload.reference,
|
||||
request_kind=payload.request_kind,
|
||||
subject=_subject(payload.subject),
|
||||
purpose=payload.purpose,
|
||||
legal_basis=payload.legal_basis,
|
||||
due_at=payload.due_at,
|
||||
requested_by_account_id=principal.account_id,
|
||||
notes=payload.notes,
|
||||
)
|
||||
_audit(session, principal, row, "privacy.dsar.created")
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return _detail(row)
|
||||
except ValueError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{request_id}", response_model=DataSubjectRequestResponse)
|
||||
def get_request(
|
||||
request_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> DataSubjectRequestResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
return _detail(_row(session, principal, request_id))
|
||||
|
||||
|
||||
@router.post("/{request_id}/search", response_model=DataSubjectRequestResponse)
|
||||
def search_request(
|
||||
request_id: str,
|
||||
payload: RevisionMutationRequest,
|
||||
request: Request,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> DataSubjectRequestResponse:
|
||||
_require(principal, MANAGE_SCOPE)
|
||||
return _mutate(
|
||||
session,
|
||||
principal,
|
||||
request_id,
|
||||
payload.base_revision,
|
||||
if_match,
|
||||
lambda row: search_data_subject_request(
|
||||
session,
|
||||
registry=_registry(request),
|
||||
row=row,
|
||||
expected_revision=payload.base_revision,
|
||||
),
|
||||
"privacy.dsar.searched",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{request_id}/erasure-plan", response_model=DataSubjectRequestResponse)
|
||||
def plan_erasure(
|
||||
request_id: str,
|
||||
payload: RevisionMutationRequest,
|
||||
request: Request,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> DataSubjectRequestResponse:
|
||||
_require(principal, MANAGE_SCOPE)
|
||||
return _mutate(
|
||||
session,
|
||||
principal,
|
||||
request_id,
|
||||
payload.base_revision,
|
||||
if_match,
|
||||
lambda row: plan_data_subject_erasure(
|
||||
session,
|
||||
registry=_registry(request),
|
||||
row=row,
|
||||
expected_revision=payload.base_revision,
|
||||
),
|
||||
"privacy.dsar.erasure_planned",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{request_id}/execute", response_model=DataSubjectRequestResponse)
|
||||
def execute_erasure(
|
||||
request_id: str,
|
||||
payload: DataSubjectExecutionRequest,
|
||||
request: Request,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> DataSubjectRequestResponse:
|
||||
_require(principal, ERASE_SCOPE)
|
||||
if payload.confirmation != f"ERASE {request_id}":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Type "ERASE {request_id}" to confirm the selected actions.',
|
||||
)
|
||||
return _mutate(
|
||||
session,
|
||||
principal,
|
||||
request_id,
|
||||
payload.base_revision,
|
||||
if_match,
|
||||
lambda row: execute_data_subject_erasure(
|
||||
session,
|
||||
registry=_registry(request),
|
||||
row=row,
|
||||
expected_revision=payload.base_revision,
|
||||
action_ids=payload.action_ids,
|
||||
),
|
||||
"privacy.dsar.erasure_executed",
|
||||
details={"action_ids": payload.action_ids},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{request_id}/export")
|
||||
def export_request(
|
||||
request_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
_require(principal, EXPORT_SCOPE)
|
||||
row = _row(session, principal, request_id)
|
||||
content = data_subject_export(row)
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
row,
|
||||
"privacy.dsar.exported",
|
||||
details={"export_bytes": len(content)},
|
||||
)
|
||||
session.commit()
|
||||
safe_reference = re.sub(r"[^A-Za-z0-9._-]+", "-", row.reference).strip("-")
|
||||
filename = f"dsar-{safe_reference or row.id}.json"
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
def _mutate(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
request_id: str,
|
||||
base_revision: int,
|
||||
if_match: str | None,
|
||||
operation: Any,
|
||||
audit_action: str,
|
||||
*,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> DataSubjectRequestResponse:
|
||||
try:
|
||||
assert_revision_precondition(
|
||||
if_match,
|
||||
resource_type="data_subject_request",
|
||||
resource_id=request_id,
|
||||
submitted_base_revision=base_revision,
|
||||
)
|
||||
row = get_data_subject_request(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
for_update=True,
|
||||
)
|
||||
operation(row)
|
||||
_audit(session, principal, row, audit_action, details=details)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return _detail(row)
|
||||
except LookupError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except MissingPreconditionError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=428, detail=exc.as_dict()) from exc
|
||||
except RevisionConflictError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail=exc.as_dict()) from exc
|
||||
except ConcurrencyError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=412, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _row(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
request_id: str,
|
||||
) -> DataSubjectRequest:
|
||||
try:
|
||||
return get_data_subject_request(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _detail(row: DataSubjectRequest) -> DataSubjectRequestResponse:
|
||||
return DataSubjectRequestResponse(
|
||||
request=data_subject_request_dict(row, include_subject=True),
|
||||
search=dict(row.search_result),
|
||||
erasure_plan=dict(row.erasure_plan),
|
||||
execution=dict(row.execution_result),
|
||||
)
|
||||
|
||||
|
||||
def _subject(payload: DataSubjectSelectorRequest) -> DsarSubjectRef:
|
||||
return DsarSubjectRef(
|
||||
account_id=_text(payload.account_id),
|
||||
identity_id=_text(payload.identity_id),
|
||||
membership_id=_text(payload.membership_id),
|
||||
email=_text(payload.email),
|
||||
external_references={
|
||||
str(key).strip(): str(value).strip()
|
||||
for key, value in payload.external_references.items()
|
||||
if str(key).strip() and str(value).strip()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _registry(request: Request) -> object:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if registry is None:
|
||||
raise HTTPException(status_code=503, detail="Module registry is unavailable.")
|
||||
return registry
|
||||
|
||||
|
||||
def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _audit(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
row: DataSubjectRequest,
|
||||
action: str,
|
||||
*,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
action=action,
|
||||
user_id=principal.membership_id,
|
||||
api_key_id=principal.api_key_id,
|
||||
object_type="data_subject_request",
|
||||
object_id=row.id,
|
||||
details={
|
||||
"reference": row.reference,
|
||||
"status": row.status,
|
||||
"resource_revision": row.resource_revision,
|
||||
**(details or {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _text(value: str | None) -> str | None:
|
||||
normalized = (value or "").strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
Reference in New Issue
Block a user