feat(records): add governed DSAR coverage

This commit is contained in:
2026-08-21 02:31:10 +02:00
parent e9d2827581
commit 38f203a906
5 changed files with 1794 additions and 0 deletions
+9
View File
@@ -14,6 +14,15 @@ Alembic migration, tenant-scoped APIs, optimistic concurrency and idempotent
filing, temporal reads, search registration, a full-height Records workspace,
and provider-neutral filing from Files and Cases.
The `privacy.dsar.records` capability exports bounded record and lifecycle
metadata from an explicit record or authoritative source linkage. Canonical
account, identity, and membership selectors return minimized staff
accountability attribution; they do not turn a staff action into record-subject
ownership. Immutable eAkte evidence is retained, while the current record fact
is routed to manual lifecycle review. Source content, snapshots, opaque
contexts, payloads, hashes, replay keys, and archive manifests/receipts are not
exported by Records.
## Initial Ownership
- file plans
+19
View File
@@ -54,6 +54,25 @@ Restricted object grants remain a separate governed slice. A target-tested
archive adapter and any destructive effect remain deliberately unimplemented;
approved destruction is only a pending lifecycle state.
## Data-subject requests
Records publishes `privacy.dsar.records`. A record-subject search must include
an exact record, revision, item, volume, chronology, hold, disposition,
transfer-package, or authoritative source-module reference. A canonical
account, identity, or membership match identifies staff accountability
activity only; creating, filing, reviewing, or transferring an eAkte does not
make that staff member the subject of its contents.
The provider exports bounded record identity, revision, filing, chronology,
hold, disposition, and transfer lifecycle metadata. It excludes source
content, record snapshots and search text, opaque institutional contexts and
payloads, digests, replay keys, launch URLs, approval identifiers, archive
manifests and receipts, and unrelated records. Exact source content remains in
the source owner's DSAR provider. Record revisions and lifecycle evidence are
immutable retention evidence; the current record fact receives a
non-executable manual-review action. Actual correction, closure, appraisal,
hold, disposition, or transfer must use the governed eAkte lifecycle.
## First Implementation Slice
Complete restricted access and one target-tested archive provider without
File diff suppressed because it is too large Load Diff
+51
View File
@@ -38,6 +38,10 @@ from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_records.backend.db import models as record_models
from govoplan_records.backend.dsar_provider import (
RECORDS_DSAR_CAPABILITY,
RecordsDsarProvider,
)
from govoplan_records.backend.search_source import create_records_search_source
from govoplan_records.backend.service import SqlRecordRegistry
from govoplan_records.backend.archive import (
@@ -94,6 +98,11 @@ def _records_registry(context: ModuleContext) -> SqlRecordRegistry:
return SqlRecordRegistry(context.registry)
def _dsar_provider(context: ModuleContext) -> RecordsDsarProvider:
del context
return RecordsDsarProvider()
def _simulated_archive_provider(
context: ModuleContext,
) -> SimulatedRecordArchiveProvider:
@@ -184,6 +193,41 @@ ROLE_TEMPLATES = (
)
DOCUMENTATION = (
DocumentationTopic(
id="records.data-subject-requests",
title="eAkte data-subject requests",
summary="Export minimized record metadata while preserving retention, hold, disposition, and archive evidence.",
body=(
"Records correlates exact record, revision, item, volume, chronology, hold, disposition, transfer-package, and provider-owned source references inside one tenant. Account, identity, and membership selectors expose staff accountability attribution but never imply that the staff member is the subject of a record. A record-subject export therefore needs an authoritative record or source linkage. "
"The export includes bounded record, filing, chronology, hold, disposition, and transfer lifecycle metadata. Source content, snapshots, search text, opaque contexts and payloads, hashes, replay keys, launch URLs, archive manifests and receipts, approval identifiers, and unrelated records are excluded. Immutable evidence is retained. The current record fact receives non-executable manual review and can change only through the governed eAkte lifecycle."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
order=90,
related_modules=(
"core",
"cases",
"files",
"forms_runtime",
"workflow_engine",
"approvals",
"dms",
),
links=(
DocumentationLink(
label="Records domain boundary",
href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
metadata={
"help_contexts": [
"records.data-subject-requests",
"records.lifecycle",
],
},
),
DocumentationTopic(
id="records.workspace",
title="eAkte workspace",
@@ -443,10 +487,12 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="records.registry", version="1.0.0"),
ModuleInterfaceProvider(name="records.filing", version="1.0.0"),
ModuleInterfaceProvider(name="records.archive", version="1.0.0"),
ModuleInterfaceProvider(name=RECORDS_DSAR_CAPABILITY, version="0.1.0"),
),
capability_factories={
CAPABILITY_RECORDS_FILING: _records_registry,
record_archive_capability(SIMULATION_PROVIDER_ID): _simulated_archive_provider,
RECORDS_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
CAPABILITY_RECORDS_FILING: CapabilityDocumentation(
@@ -461,6 +507,11 @@ manifest = ModuleManifest(
),
contract_version="1.0.0",
),
RECORDS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Records data-subject request provider",
summary="Finds minimized eAkte and operator-attribution evidence without bypassing record retention.",
contract_version="0.1.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
+652
View File
@@ -0,0 +1,652 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_records.backend.db.models import (
RecordChronologyEntry,
RecordClassRevision,
RecordDispositionRevision,
RecordFilePlanRevision,
RecordHoldRevision,
RecordIdentity,
RecordItem,
RecordRevision,
RecordTransferPackageRevision,
RecordVolumeRevision,
)
from govoplan_records.backend.dsar_provider import (
RECORDS_DSAR_CAPABILITY,
RecordsDsarProvider,
)
from govoplan_records.backend.manifest import manifest
NOW = datetime(2026, 8, 21, 15, 0, tzinfo=UTC)
class _Registry:
def __init__(self, provider: RecordsDsarProvider, *, active: bool = True) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (RECORDS_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "records"
def tenant_entitlement_resolver(self):
active = self.active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("records",) if active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "records"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != RECORDS_DSAR_CAPABILITY:
raise KeyError(name)
class RecordsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = RecordsDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed_record_package()
self._seed_unrelated_records()
self._seed_operator_configuration()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed_record_package(self) -> None:
identity = RecordIdentity(
id="identity-row-1",
tenant_id="tenant-1",
record_id="record-1",
record_number="2026/0001",
created_by="clerk-1",
)
self.session.add(identity)
self.session.flush()
self.session.add_all(
(
RecordRevision(
id="revision-row-1",
tenant_id="tenant-1",
record_id="record-1",
identity_id=identity.id,
revision=1,
class_id="class-permit",
file_plan_node_id="plan-permits",
title="Resident parking permit record",
description="Application by Ada Example",
state="retention_running",
source_authority_mode="native_authoritative",
access_mode="tenant",
purpose="decide resident parking permit",
classification="personal",
responsible_unit_id="unit-1",
responsible_function_id="function-permits",
external_reference={
"case_id": "case-1",
"account_id": "applicant-1",
"secret": "private-external-reference-do-not-export",
},
institutional_context={
"membership_id": "membership-applicant-1",
"secret": "private-context-do-not-export",
},
search_text="private-search-text-do-not-export",
valid_from=NOW,
recorded_at=NOW,
superseded_at=NOW + timedelta(minutes=1),
changed_by="clerk-1",
snapshot={"secret": "private-snapshot-do-not-export"},
),
RecordRevision(
id="revision-row-2",
tenant_id="tenant-1",
record_id="record-1",
identity_id=identity.id,
revision=2,
previous_revision_id="revision-row-1",
class_id="class-permit",
file_plan_node_id="plan-permits",
title="Resident parking permit record",
description="Application by Ada Example",
state="retention_running",
source_authority_mode="native_authoritative",
access_mode="tenant",
purpose="decide resident parking permit",
classification="personal",
responsible_unit_id="unit-1",
responsible_function_id="function-permits",
external_reference={
"case_id": "case-1",
"account_id": "applicant-1",
"secret": "private-external-reference-do-not-export",
},
institutional_context={
"membership_id": "membership-applicant-1",
"secret": "private-context-do-not-export",
},
search_text="private-search-text-do-not-export",
valid_from=NOW,
recorded_at=NOW + timedelta(minutes=1),
changed_by="clerk-1",
closed_at=NOW + timedelta(minutes=1),
retention_started_at=NOW + timedelta(minutes=1),
retention_due_at=NOW + timedelta(days=3650),
retention_rule={"secret": "private-rule-do-not-export"},
appraisal_state="appraised",
appraisal={"secret": "private-appraisal-do-not-export"},
snapshot={"secret": "private-snapshot-do-not-export"},
),
)
)
self.session.add(
RecordVolumeRevision(
id="volume-row-1",
tenant_id="tenant-1",
volume_id="volume-1",
record_id="record-1",
revision=1,
sequence=1,
label="Application",
state="closed",
valid_from=NOW,
recorded_at=NOW,
changed_by="clerk-1",
)
)
self.session.add(
RecordItem(
id="item-row-1",
tenant_id="tenant-1",
record_id="record-1",
volume_id="volume-1",
sequence=1,
source_module="cases",
resource_type="case_revision",
resource_id="case-1",
source_revision="revision-2",
label="Permit case",
relationship="decision_basis",
filing_reason="The application belongs to this record.",
purpose="document the permit decision",
authority_mode="native_authoritative",
content_sha256="a" * 64,
content_type="application/json",
size_bytes=2048,
source_valid_from=NOW,
source_recorded_at=NOW,
launch_url="https://secret.example/do-not-export",
filed_at=NOW + timedelta(minutes=1),
filed_by="clerk-1",
actor_assignment_id="private-assignment-do-not-export",
actor_delegation_id="private-delegation-do-not-export",
institutional_context={"secret": "private-item-context-do-not-export"},
source_metadata={"secret": "private-source-data-do-not-export"},
filing_metadata={"secret": "private-filing-data-do-not-export"},
idempotency_key="private-item-key-do-not-export",
request_sha256="b" * 64,
)
)
self.session.add(
RecordChronologyEntry(
id="chronology-row-1",
tenant_id="tenant-1",
record_id="record-1",
event_id="event-1",
event_type="record.item_filed",
record_revision=2,
summary="private-event-summary-do-not-export",
occurred_at=NOW + timedelta(minutes=1),
actor_id="clerk-1",
actor_assignment_id="private-assignment-do-not-export",
actor_delegation_id="private-delegation-do-not-export",
purpose="document the permit decision",
idempotency_key="private-event-key-do-not-export",
request_sha256="c" * 64,
institutional_context={"secret": "private-event-context-do-not-export"},
payload={"secret": "private-event-payload-do-not-export"},
)
)
self.session.add(
RecordHoldRevision(
id="hold-row-1",
tenant_id="tenant-1",
hold_id="hold-1",
record_id="record-1",
revision=1,
status="released",
reason="private-hold-reason-do-not-export",
authority="private-hold-authority-do-not-export",
scope={"secret": "private-hold-scope-do-not-export"},
effective_from=NOW + timedelta(minutes=2),
released_at=NOW + timedelta(minutes=3),
policy_refs=["private-policy-do-not-export"],
institutional_context={"secret": "private-hold-context-do-not-export"},
recorded_at=NOW + timedelta(minutes=2),
changed_by="clerk-1",
idempotency_key="private-hold-key-do-not-export",
request_sha256="d" * 64,
)
)
self.session.add(
RecordDispositionRevision(
id="disposition-row-1",
tenant_id="tenant-1",
disposition_id="disposition-1",
record_id="record-1",
revision=1,
action="transfer",
status="approved",
reason="private-disposition-reason-do-not-export",
subject_revision=2,
subject_sha256="e" * 64,
consequence_preview={"secret": "private-preview-do-not-export"},
policy_refs=["private-policy-do-not-export"],
approval_request_id="private-approval-do-not-export",
proposed_by="clerk-1",
reviewed_by="reviewer-1",
reviewed_at=NOW + timedelta(minutes=4),
institutional_context={
"secret": "private-disposition-context-do-not-export"
},
recorded_at=NOW + timedelta(minutes=3),
idempotency_key="private-disposition-key-do-not-export",
request_sha256="f" * 64,
)
)
self.session.add(
RecordTransferPackageRevision(
id="transfer-row-1",
tenant_id="tenant-1",
package_id="package-1",
record_id="record-1",
disposition_id="disposition-1",
revision=1,
record_revision=2,
provider_id="archive-1",
profile="xarchive",
status="accepted",
authority_mode="external_authoritative",
manifest={"secret": "private-manifest-do-not-export"},
manifest_sha256="1" * 64,
receipt={"secret": "private-receipt-do-not-export"},
receipt_sha256="2" * 64,
external_reference="private-archive-reference-do-not-export",
recovery_operation_id="private-recovery-id-do-not-export",
simulated=False,
institutional_context={
"secret": "private-transfer-context-do-not-export"
},
recorded_at=NOW + timedelta(minutes=5),
changed_by="clerk-1",
idempotency_key="private-transfer-key-do-not-export",
request_sha256="3" * 64,
)
)
def _seed_unrelated_records(self) -> None:
for suffix, tenant_id in (("2", "tenant-1"), ("3", "tenant-2")):
identity = RecordIdentity(
id=f"identity-row-{suffix}",
tenant_id=tenant_id,
record_id=f"record-{suffix}",
record_number=f"2026/000{suffix}",
created_by="other-clerk",
)
self.session.add(identity)
self.session.flush()
self.session.add(
RecordRevision(
id=f"unrelated-revision-row-{suffix}",
tenant_id=tenant_id,
record_id=f"record-{suffix}",
identity_id=identity.id,
revision=1,
class_id="class-permit",
file_plan_node_id="plan-permits",
title=f"Unrelated record {suffix}",
description="unrelated-private-description",
state="open",
source_authority_mode="native_authoritative",
access_mode="tenant",
purpose="unrelated purpose",
external_reference={},
institutional_context={},
search_text="unrelated-private-search",
recorded_at=NOW,
changed_by="other-clerk",
snapshot={"secret": "unrelated-private-snapshot"},
)
)
def _seed_operator_configuration(self) -> None:
self.session.add(
RecordFilePlanRevision(
id="plan-row-1",
tenant_id="tenant-1",
node_id="plan-permits",
revision=1,
code="10.20",
label="Permits",
active=True,
recorded_at=NOW,
institutional_context={"secret": "private-plan-context-do-not-export"},
changed_by="clerk-1",
idempotency_key="private-plan-key-do-not-export",
request_sha256="4" * 64,
)
)
self.session.add(
RecordClassRevision(
id="class-row-1",
tenant_id="tenant-1",
class_id="class-permit",
revision=1,
file_plan_node_id="plan-permits",
key="permit.application",
label="Permit application",
metadata_requirements=["private-metadata-rule-do-not-export"],
allowed_source_types=["cases:case_revision"],
retention_period_days=3650,
access_mode="tenant",
active=True,
recorded_at=NOW,
institutional_context={"secret": "private-class-context-do-not-export"},
changed_by="clerk-1",
idempotency_key="private-class-key-do-not-export",
request_sha256="5" * 64,
)
)
def test_direct_record_exports_minimized_complete_lifecycle(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="applicant-1",
external_references={"records.record": "record-1"},
),
)
resource_types = {record.resource_type for record in records}
self.assertTrue(
{
"record_identity",
"record_revision",
"record_current_fact",
"record_volume_revision",
"record_item",
"record_chronology_entry",
"record_hold_revision",
"record_disposition_revision",
"record_transfer_package_revision",
}.issubset(resource_types)
)
self.assertNotIn(
"record-2", json.dumps([record.to_dict() for record in records])
)
exported = json.dumps([record.to_dict() for record in records])
for secret in (
"private-snapshot-do-not-export",
"private-search-text-do-not-export",
"private-context-do-not-export",
"private-event-summary-do-not-export",
"private-event-payload-do-not-export",
"private-hold-reason-do-not-export",
"private-preview-do-not-export",
"private-manifest-do-not-export",
"private-receipt-do-not-export",
"private-archive-reference-do-not-export",
"private-item-key-do-not-export",
):
self.assertNotIn(secret, exported)
def test_authoritative_source_reference_correlates_the_record(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="applicant-1",
external_references={"cases.case": "case-1"},
),
)
item = next(
record for record in records if record.resource_type == "record_item"
)
self.assertTrue(item.data["source_reference_matches"])
self.assertEqual("record-1", item.data["record_id"])
def test_operator_search_returns_attribution_without_record_contents(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="clerk-1"),
)
self.assertTrue(records)
self.assertEqual(
{"records_operator_attribution"},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertNotIn("Application by Ada Example", exported)
self.assertNotIn("private-", exported)
self.assertIn("changed_file_plan", exported)
def test_direct_selectors_fail_closed_on_conflict_or_wrong_tenant(self) -> None:
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={
"records.record": "record-2",
"records.item": "item-row-1",
}
),
)
alias_conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={
"records.record": "record-1",
"records.record_id": "record-2",
}
),
)
wrong_tenant = self.provider.search_subject(
self.session,
tenant_id="tenant-2",
subject=DsarSubjectRef(external_references={"records.record": "record-1"}),
)
mismatched_actor = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="someone-else",
external_references={"records.record": "record-1"},
),
)
self.assertEqual((), conflict)
self.assertEqual((), alias_conflict)
self.assertEqual((), wrong_tenant)
self.assertEqual((), mismatched_actor)
def test_plan_retains_evidence_and_routes_current_fact_to_review(self) -> None:
subject = DsarSubjectRef(external_references={"records.record": "record-1"})
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertEqual(1, sum(action.kind == "manual_review" for action in actions))
self.assertGreater(sum(action.kind == "retain" for action in actions), 7)
self.assertTrue(all(not action.executable for action in actions))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
self.assertTrue(all(result.status == "blocked" for result in results))
def test_foreign_records_and_actions_are_rejected(self) -> None:
subject = DsarSubjectRef(external_references={"records.record": "record-1"})
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(
DsarRecordRef(
provider_id="cases",
module_id="cases",
resource_type="case",
resource_id="case-1",
category="case",
title="Case",
),
),
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(
DsarErasureActionRef(
action_id="cases:retain:case:case-1",
provider_id="cases",
module_id="cases",
kind="retain",
resource_type="case",
resource_id="case-1",
title="Retain case",
rationale="Evidence",
executable=False,
),
),
request_id="dsar-1",
)
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-RECORDS-1",
request_kind="access",
subject=DsarSubjectRef(external_references={"records.record": "record-1"}),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=1,
)
self.assertEqual(
[RECORDS_DSAR_CAPABILITY], row.coverage["provider_capabilities"]
)
self.assertGreater(row.search_result["record_count"], 8)
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-RECORDS-2",
request_kind="access",
subject=DsarSubjectRef(external_references={"records.record": "record-1"}),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual([], inactive.coverage["provider_capabilities"])
self.assertEqual(
[RECORDS_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertEqual(0, inactive.search_result["record_count"])
def test_manifest_registers_and_documents_the_capability(self) -> None:
self.assertIn(RECORDS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(RECORDS_DSAR_CAPABILITY, manifest.capability_documentation)
self.assertIn(
RECORDS_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "records.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()