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
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.dsar import DsarRecordRef
from govoplan_core.db.base import Base
from govoplan_core.db.session import get_session
from govoplan_core.privacy.dsar_workflow import DataSubjectRequest
from govoplan_core.server.dsar import router
class _Provider:
provider_id = "example"
module_id = "example"
def search_subject(self, session, *, tenant_id, subject):
del session, tenant_id, subject
return (
DsarRecordRef(
provider_id="example",
module_id="example",
resource_type="profile",
resource_id="profile-1",
category="profile",
title="Example profile",
),
)
def plan_erasure(self, session, *, tenant_id, subject, records):
del session, tenant_id, subject, records
return ()
def execute_erasure(self, session, *, tenant_id, subject, actions, request_id):
del session, tenant_id, subject, actions, request_id
return ()
class _Registry:
provider = _Provider()
def capability_names(self):
return ("privacy.dsar.example",)
def require_tenant_capability(self, name, session, **kwargs):
del name, session, kwargs
return self.provider
def manifests(self):
return (SimpleNamespace(id="example"),)
class DsarApiTests(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 = sessionmaker(bind=self.engine, expire_on_commit=False)()
self.principal = _principal(
"access:privacy:read",
"access:privacy:manage",
"access:privacy:export",
"access:privacy:erase",
)
app = FastAPI()
app.state.govoplan_registry = _Registry()
app.include_router(router, prefix="/api/v1")
app.dependency_overrides[get_session] = lambda: self.session
app.dependency_overrides[get_api_principal] = lambda: self.principal
self.audit_patch = patch("govoplan_core.server.dsar.audit_event")
self.audit_patch.start()
self.client = TestClient(app)
def tearDown(self) -> None:
self.client.close()
self.audit_patch.stop()
self.session.close()
Base.metadata.drop_all(self.engine, tables=[DataSubjectRequest.__table__])
self.engine.dispose()
def test_mutation_requires_strong_revision_and_export_is_available(self) -> None:
created = self.client.post(
"/api/v1/admin/privacy/data-subject-requests",
json={
"reference": "DSAR-1",
"request_kind": "access",
"subject": {"email": "ada@example.test"},
"purpose": "Verified access request",
},
)
self.assertEqual(201, created.status_code, created.text)
item = created.json()["request"]
missing = self.client.post(
f"/api/v1/admin/privacy/data-subject-requests/{item['id']}/search",
json={"base_revision": item["resource_revision"]},
)
self.assertEqual(428, missing.status_code, missing.text)
searched = self.client.post(
f"/api/v1/admin/privacy/data-subject-requests/{item['id']}/search",
headers={"If-Match": item["etag"]},
json={"base_revision": item["resource_revision"]},
)
self.assertEqual(200, searched.status_code, searched.text)
self.assertEqual(1, searched.json()["search"]["record_count"])
stale = self.client.post(
f"/api/v1/admin/privacy/data-subject-requests/{item['id']}/search",
headers={"If-Match": item["etag"]},
json={"base_revision": item["resource_revision"]},
)
self.assertEqual(409, stale.status_code, stale.text)
exported = self.client.get(
f"/api/v1/admin/privacy/data-subject-requests/{item['id']}/export"
)
self.assertEqual(200, exported.status_code, exported.text)
self.assertIn("govoplan.dsars.export.v1", exported.text)
def test_privacy_scopes_are_independent(self) -> None:
self.principal = _principal("access:privacy:read")
denied = self.client.post(
"/api/v1/admin/privacy/data-subject-requests",
json={
"reference": "DSAR-2",
"request_kind": "access",
"subject": {"email": "ada@example.test"},
"purpose": "Verified access request",
},
)
self.assertEqual(403, denied.status_code, denied.text)
self.assertEqual(200, self.client.get("/api/v1/admin/privacy/data-subject-requests").status_code)
def _principal(*scopes: str) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-operator",
membership_id="membership-operator",
tenant_id="tenant-1",
scopes=frozenset(scopes),
),
account=SimpleNamespace(id="account-operator"),
user=SimpleNamespace(id="membership-operator"),
)
if __name__ == "__main__":
unittest.main()
+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()
+21
View File
@@ -10,8 +10,11 @@ from govoplan_core.core.files import (
)
from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
CAPABILITY_TEMPLATE_RENDERER,
TemplateCatalogProvider,
TemplateContentDraftRequest,
TemplateContentLibraryProvider,
TemplateRenderRequest,
TemplateRendererProvider,
)
@@ -37,6 +40,12 @@ class _Renderer:
return None
class _ContentLibrary:
def create_content_draft(self, session, principal, *, request):
del session, principal, request
return None
class _Store:
def store_artifact(self, session, principal, *, request):
del session, principal
@@ -54,10 +63,14 @@ class _Store:
class TemplateContractTests(unittest.TestCase):
def test_capability_names_and_runtime_protocols_are_stable(self) -> None:
self.assertEqual("templates.catalog", CAPABILITY_TEMPLATE_CATALOG)
self.assertEqual(
"templates.content_library", CAPABILITY_TEMPLATE_CONTENT_LIBRARY
)
self.assertEqual("templates.renderer", CAPABILITY_TEMPLATE_RENDERER)
self.assertEqual("files.artifact_store", CAPABILITY_FILES_ARTIFACT_STORE)
self.assertIsInstance(_Catalog(), TemplateCatalogProvider)
self.assertIsInstance(_Renderer(), TemplateRendererProvider)
self.assertIsInstance(_ContentLibrary(), TemplateContentLibraryProvider)
self.assertIsInstance(_Store(), ManagedArtifactStore)
def test_requests_do_not_expose_consumer_or_files_models(self) -> None:
@@ -71,6 +84,14 @@ class TemplateContractTests(unittest.TestCase):
self.assertEqual("preview", render.mode)
self.assertEqual("Generated", artifact.folder)
content = TemplateContentDraftRequest(
name="Closing paragraph",
template_type="content_fragment",
usages=("campaign.content",),
content_text="Kind regards",
)
self.assertEqual("de", content.locale)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -69,7 +69,7 @@ class WheelRuntimeTests(unittest.TestCase):
self.assertNotEqual(repository_root, runtime_root)
self.assertTrue((runtime_root / "alembic.ini").is_file())
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
self.assertEqual(["e14b8c2d6f90"], result["heads"])
self.assertEqual(["b47e6f809a13"], result["heads"])
self.assertIn("core_scopes", result["tables"])
self.assertIn("core_system_settings", result["tables"])