feat: add governed DSAR workflow

This commit is contained in:
2026-08-07 14:53:07 +02:00
parent eb0c01c5d2
commit e6457b3f6b
19 changed files with 2239 additions and 2 deletions
+236
View File
@@ -0,0 +1,236 @@
from __future__ import annotations
import json
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from govoplan_core.core.concurrency import RevisionConflictError
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
DataSubjectRequest,
create_data_subject_request,
data_subject_export,
execute_data_subject_erasure,
plan_data_subject_erasure,
search_data_subject_request,
)
class _Provider:
provider_id = "example"
module_id = "example"
def __init__(self) -> None:
self.executions = 0
def search_subject(self, session, *, tenant_id, subject):
del session, tenant_id, subject
return (
DsarRecordRef(
provider_id=self.provider_id,
module_id=self.module_id,
resource_type="profile",
resource_id="profile-1",
category="personal",
title="Example profile",
data={"name": "Ada"},
),
DsarRecordRef(
provider_id=self.provider_id,
module_id=self.module_id,
resource_type="audit_evidence",
resource_id="event-1",
category="evidence",
title="Decision event",
immutable_evidence=True,
retention_reason="Required decision evidence.",
),
)
def plan_erasure(self, session, *, tenant_id, subject, records):
del session, tenant_id, subject, records
return (
DsarErasureActionRef(
action_id="example:anonymize:profile-1",
provider_id=self.provider_id,
module_id=self.module_id,
kind="anonymize",
resource_type="profile",
resource_id="profile-1",
title="Anonymize profile",
rationale="Remove profile data.",
executable=True,
irreversible=True,
),
)
def execute_erasure(self, session, *, tenant_id, subject, actions, request_id):
del session, tenant_id, subject, request_id
self.executions += 1
return tuple(
DsarExecutionResultRef(
action_id=action.action_id,
status="executed",
summary="Profile anonymized.",
)
for action in actions
)
class _Registry:
def __init__(self, provider: _Provider) -> None:
self.provider = provider
def capability_names(self):
return ("privacy.dsar.example", "privacy.dsar.inactive")
def capability_owner(self, name):
return {
"privacy.dsar.example": "example",
"privacy.dsar.inactive": "inactive",
}[name]
def tenant_entitlement_resolver(self):
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("example", "without-provider")},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del name, session, kwargs
return self.provider
def manifests(self):
return (
type("Manifest", (), {"id": "example"})(),
type("Manifest", (), {"id": "without-provider"})(),
)
class DsarWorkflowTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
DataSubjectRequest.__table__.create(self.engine)
self.session: Session = sessionmaker(
bind=self.engine,
expire_on_commit=False,
)()
self.provider = _Provider()
self.registry = _Registry(self.provider)
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(self.engine, tables=[DataSubjectRequest.__table__])
self.engine.dispose()
def test_search_plan_export_and_idempotent_execution(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-2026-001",
request_kind="access_and_erasure",
subject=DsarSubjectRef(email="ada@example.test"),
purpose="Respond to a verified data-subject request.",
legal_basis="Article 15 and 17 GDPR",
due_at=None,
requested_by_account_id="account-operator",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=self.registry,
row=row,
expected_revision=1,
)
self.assertEqual("searched", row.status)
self.assertEqual(2, row.search_result["record_count"])
self.assertEqual(["without-provider"], row.coverage["modules_without_provider"])
self.assertEqual(
["privacy.dsar.inactive"],
row.coverage["inactive_provider_capabilities"],
)
plan_data_subject_erasure(
self.session,
registry=self.registry,
row=row,
expected_revision=2,
)
actions = row.erasure_plan["actions"]
self.assertEqual(2, len(actions))
self.assertEqual(1, row.erasure_plan["retained_count"])
executable_id = next(item["action_id"] for item in actions if item["executable"])
execute_data_subject_erasure(
self.session,
registry=self.registry,
row=row,
expected_revision=3,
action_ids=[executable_id],
)
self.assertEqual("completed", row.status)
self.assertEqual(1, self.provider.executions)
export = json.loads(data_subject_export(row))
self.assertEqual("govoplan.dsars.export.v1", export["schema"])
self.assertEqual("DSAR-2026-001", export["request"]["reference"])
self.assertEqual(64, len(export["manifest_sha256"]))
with self.assertRaises(RevisionConflictError):
search_data_subject_request(
self.session,
registry=self.registry,
row=row,
expected_revision=1,
)
def test_access_only_request_cannot_create_erasure_plan(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-2026-002",
request_kind="access",
subject=DsarSubjectRef(account_id="account-1"),
purpose="Provide access information.",
legal_basis=None,
due_at=None,
requested_by_account_id="account-operator",
)
search_data_subject_request(
self.session,
registry=self.registry,
row=row,
expected_revision=1,
)
with self.assertRaisesRegex(ValueError, "does not include erasure"):
plan_data_subject_erasure(
self.session,
registry=self.registry,
row=row,
expected_revision=2,
)
if __name__ == "__main__":
unittest.main()