Files
govoplan-records/tests/test_dsar_provider.py
T
zemion a2dd280c47
Module Package Release / publish-packages (push) Successful in 13s
feat(records): enforce purpose-bound restricted access
2026-08-24 19:07:04 +02:00

692 lines
26 KiB
Python

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 (
RecordAccessGrantRevision,
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_account_search_exports_only_the_subjects_restricted_grant(self) -> None:
self.session.add(
RecordAccessGrantRevision(
id="grant-row-subject-1",
tenant_id="tenant-1",
grant_id="grant-subject-1",
record_id="record-1",
revision=1,
status="active",
subject_type="account",
subject_id="account-grantee-1",
actions=["read"],
allowed_purposes=["case-work"],
reason="Sensitive internal assignment reason",
valid_from=NOW,
recorded_at=NOW,
changed_by="records-admin-1",
institutional_context={"case_id": "case-1"},
idempotency_key="grant-subject-create-1",
request_sha256="a" * 64,
)
)
self.session.commit()
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-grantee-1"),
)
grants = [
record
for record in records
if record.resource_type == "record_access_grant_revision"
]
self.assertEqual(1, len(grants))
self.assertEqual(["case-work"], grants[0].data["allowed_purposes"])
self.assertNotIn("reason", grants[0].data)
self.assertNotIn("account-grantee-1", json.dumps(grants[0].data))
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()