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