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