From 38f203a9060a52aa69a95fcde971a63594403508 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 21 Aug 2026 02:31:10 +0200 Subject: [PATCH] feat(records): add governed DSAR coverage --- README.md | 9 + docs/RECORDS_DOMAIN_BOUNDARY.md | 19 + src/govoplan_records/backend/dsar_provider.py | 1063 +++++++++++++++++ src/govoplan_records/backend/manifest.py | 51 + tests/test_dsar_provider.py | 652 ++++++++++ 5 files changed, 1794 insertions(+) create mode 100644 src/govoplan_records/backend/dsar_provider.py create mode 100644 tests/test_dsar_provider.py diff --git a/README.md b/README.md index b1593c4..7a6d06f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/RECORDS_DOMAIN_BOUNDARY.md b/docs/RECORDS_DOMAIN_BOUNDARY.md index 61314ec..3d7bed6 100644 --- a/docs/RECORDS_DOMAIN_BOUNDARY.md +++ b/docs/RECORDS_DOMAIN_BOUNDARY.md @@ -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 diff --git a/src/govoplan_records/backend/dsar_provider.py b/src/govoplan_records/backend/dsar_provider.py new file mode 100644 index 0000000..d8a4679 --- /dev/null +++ b/src/govoplan_records/backend/dsar_provider.py @@ -0,0 +1,1063 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarExecutionResultRef, + DsarRecordRef, + DsarSubjectRef, + dsar_capability_name, +) +from govoplan_records.backend.db.models import ( + RecordChronologyEntry, + RecordClassRevision, + RecordDispositionRevision, + RecordFilePlanRevision, + RecordHoldRevision, + RecordIdentity, + RecordItem, + RecordRevision, + RecordTransferPackageRevision, + RecordVolumeRevision, +) + + +RECORDS_DSAR_CAPABILITY = dsar_capability_name("records") +_MAX_RECORDS = 5_000 +_SOURCE_MODULES = frozenset( + { + "campaign", + "campaigns", + "cases", + "decisions", + "dms", + "docs", + "files", + "forms_runtime", + "mail", + "postbox", + "workflow", + "workflow_engine", + } +) +_REFERENCE_ALIASES = { + "records.record", + "records.record_id", + "records.revision", + "records.record_revision", + "records.item", + "records.volume", + "records.chronology", + "records.hold", + "records.disposition", + "records.transfer_package", + "records.account", + "records.identity", + "records.membership", + "access.account", + "identity.id", + "tenancy.membership", +} + + +@dataclass(frozen=True, slots=True) +class _SourceReference: + module_id: str + resource_type: str + resource_id: str + + +@dataclass(frozen=True, slots=True) +class _SubjectSelectors: + account_id: str | None + identity_id: str | None + membership_id: str | None + record_id: str | None + revision_id: str | None + item_id: str | None + volume_id: str | None + chronology_id: str | None + hold_id: str | None + disposition_id: str | None + transfer_package_id: str | None + source_references: tuple[_SourceReference, ...] + + @property + def actor_ids(self) -> tuple[str, ...]: + return tuple( + value + for value in (self.account_id, self.identity_id, self.membership_id) + if value + ) + + @property + def has_canonical_selector(self) -> bool: + return bool(self.actor_ids) + + @property + def has_direct_selector(self) -> bool: + return bool( + self.record_id + or self.revision_id + or self.item_id + or self.volume_id + or self.chronology_id + or self.hold_id + or self.disposition_id + or self.transfer_package_id + or self.source_references + ) + + +class RecordsDsarProvider: + provider_id = "records" + module_id = "records" + + def search_subject( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + ) -> Sequence[DsarRecordRef]: + db = _session(session) + selectors = _subject_selectors(subject) + if selectors is None or not ( + selectors.has_canonical_selector or selectors.has_direct_selector + ): + return () + + direct_record_ids = _resolve_direct_record_ids( + db, + tenant_id=tenant_id, + selectors=selectors, + ) + if direct_record_ids is None or len(direct_record_ids) > 1: + return () + + if ( + direct_record_ids + and selectors.has_canonical_selector + and not selectors.source_references + and not _canonical_correlates_record( + db, + tenant_id=tenant_id, + record_id=next(iter(direct_record_ids)), + selectors=selectors, + ) + ): + return () + + records: list[DsarRecordRef] = [] + seen: set[tuple[str, str]] = set() + + def append(record: DsarRecordRef) -> None: + key = (record.resource_type, record.resource_id) + if key in seen: + return + if len(records) >= _MAX_RECORDS: + raise ValueError( + "Records DSAR result limit exceeded; narrow the selectors." + ) + seen.add(key) + records.append(record) + + if direct_record_ids: + _append_record_package( + db, + tenant_id=tenant_id, + record_id=next(iter(direct_record_ids)), + selectors=selectors, + append=append, + ) + + _append_operator_attribution( + db, + tenant_id=tenant_id, + selectors=selectors, + record_ids=direct_record_ids, + append=append, + ) + return tuple(records) + + def plan_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + records: Sequence[DsarRecordRef], + ) -> Sequence[DsarErasureActionRef]: + del tenant_id + _session(session) + if _subject_selectors(subject) is None: + raise ValueError("Records DSAR subject selectors conflict.") + actions: list[DsarErasureActionRef] = [] + for record in records: + _validate_record(record) + if record.immutable_evidence: + kind = "retain" + title = f"Retain {record.title}" + rationale = record.retention_reason or ( + "Institutional record evidence must remain attributable." + ) + else: + kind = "manual_review" + title = f"Review {record.title}" + rationale = ( + "An authorized records manager must correct, close, appraise, " + "hold, transfer, or dispose of the current fact through the " + "governed eAkte lifecycle after legal and third-party review." + ) + actions.append( + DsarErasureActionRef( + action_id=( + f"records:{kind}:{record.resource_type}:{record.resource_id}" + ), + provider_id=self.provider_id, + module_id=self.module_id, + kind=kind, + resource_type=record.resource_type, + resource_id=record.resource_id, + title=title, + rationale=rationale, + executable=False, + ) + ) + return tuple(actions) + + def execute_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + actions: Sequence[DsarErasureActionRef], + request_id: str, + ) -> Sequence[DsarExecutionResultRef]: + del tenant_id + _session(session) + if _subject_selectors(subject) is None: + raise ValueError("Records DSAR subject selectors conflict.") + results: list[DsarExecutionResultRef] = [] + for action in actions: + _validate_action(action) + if action.executable: + raise ValueError( + "Records DSAR does not publish executable erasure actions." + ) + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="blocked", + summary=( + "Use the governed record, hold, disposition, or archive " + "lifecycle after retention, evidence, and third-party review." + ), + evidence={"request_id": request_id}, + ) + ) + return tuple(results) + + +def _resolve_direct_record_ids( + session: Session, + *, + tenant_id: str, + selectors: _SubjectSelectors, +) -> set[str] | None: + resolved: list[set[str]] = [] + definitions = ( + (selectors.record_id, RecordIdentity, "record_id", "record_id"), + (selectors.revision_id, RecordRevision, "id", "record_id"), + (selectors.item_id, RecordItem, "id", "record_id"), + (selectors.volume_id, RecordVolumeRevision, "id", "record_id"), + ( + selectors.chronology_id, + RecordChronologyEntry, + "id", + "record_id", + ), + (selectors.hold_id, RecordHoldRevision, "id", "record_id"), + ( + selectors.disposition_id, + RecordDispositionRevision, + "id", + "record_id", + ), + ( + selectors.transfer_package_id, + RecordTransferPackageRevision, + "id", + "record_id", + ), + ) + for value, model, match_field, record_field in definitions: + if not value: + continue + rows = ( + session.query(model) + .filter( + model.tenant_id == tenant_id, + getattr(model, match_field) == value, + ) + .all() + ) + if not rows: + return None + resolved.append({str(getattr(row, record_field)) for row in rows}) + + for reference in selectors.source_references: + rows = ( + session.query(RecordItem) + .filter( + RecordItem.tenant_id == tenant_id, + RecordItem.source_module == reference.module_id, + RecordItem.resource_id == reference.resource_id, + ) + .all() + ) + if not rows: + return None + resolved.append({row.record_id for row in rows}) + + if not resolved: + return set() + record_ids = set.intersection(*resolved) + return record_ids or None + + +def _canonical_correlates_record( + session: Session, + *, + tenant_id: str, + record_id: str, + selectors: _SubjectSelectors, +) -> bool: + actor_ids = selectors.actor_ids + if not actor_ids: + return True + if _record_actor_matches( + session, + tenant_id=tenant_id, + record_id=record_id, + actor_ids=actor_ids, + ): + return True + rows = _rows_for_record( + session, + RecordRevision, + tenant_id=tenant_id, + record_id=record_id, + ) + return any( + _mapping_contains_subject(row.external_reference, selectors) + or _mapping_contains_subject(row.institutional_context, selectors) + for row in rows + ) + + +def _record_actor_matches( + session: Session, + *, + tenant_id: str, + record_id: str, + actor_ids: tuple[str, ...], +) -> bool: + definitions = ( + (RecordIdentity, (RecordIdentity.created_by,)), + (RecordRevision, (RecordRevision.changed_by,)), + (RecordVolumeRevision, (RecordVolumeRevision.changed_by,)), + (RecordItem, (RecordItem.filed_by,)), + (RecordChronologyEntry, (RecordChronologyEntry.actor_id,)), + (RecordHoldRevision, (RecordHoldRevision.changed_by,)), + ( + RecordDispositionRevision, + ( + RecordDispositionRevision.proposed_by, + RecordDispositionRevision.reviewed_by, + ), + ), + (RecordTransferPackageRevision, (RecordTransferPackageRevision.changed_by,)), + ) + for model, fields in definitions: + if ( + session.query(model.id) + .filter( + model.tenant_id == tenant_id, + model.record_id == record_id, + or_(*(field.in_(actor_ids) for field in fields)), + ) + .first() + is not None + ): + return True + return False + + +def _mapping_contains_subject( + value: Mapping[str, object] | object, + selectors: _SubjectSelectors, +) -> bool: + if not isinstance(value, Mapping): + return False + expected = { + "account_id": selectors.account_id, + "identity_id": selectors.identity_id, + "membership_id": selectors.membership_id, + } + return any( + candidate is not None and str(value.get(key) or "").strip() == candidate + for key, candidate in expected.items() + ) + + +def _append_record_package( + session: Session, + *, + tenant_id: str, + record_id: str, + selectors: _SubjectSelectors, + append: object, +) -> None: + identity = ( + session.query(RecordIdentity) + .filter( + RecordIdentity.tenant_id == tenant_id, + RecordIdentity.record_id == record_id, + ) + .one_or_none() + ) + if identity is None: + return + append( # type: ignore[operator] + _record( + "record_identity", + identity.id, + "institutional_record_identity", + f"Record {identity.record_number}", + { + "record_id": identity.record_id, + "record_number": identity.record_number, + "created_at": _iso(identity.created_at), + }, + observed_at=identity.created_at, + immutable=True, + retention_reason=( + "The stable record identity is institutional evidence and remains " + "subject to the configured record-class lifecycle." + ), + ) + ) + + revisions = _rows_for_record( + session, + RecordRevision, + tenant_id=tenant_id, + record_id=record_id, + ) + for row in revisions: + append(_record_revision(row)) # type: ignore[operator] + if row.superseded_at is None: + append(_current_record_fact(row)) # type: ignore[operator] + + for row in _rows_for_record( + session, + RecordVolumeRevision, + tenant_id=tenant_id, + record_id=record_id, + ): + append(_volume_record(row)) # type: ignore[operator] + for row in _rows_for_record( + session, + RecordItem, + tenant_id=tenant_id, + record_id=record_id, + ): + append(_item_record(row, selectors=selectors)) # type: ignore[operator] + for row in _rows_for_record( + session, + RecordChronologyEntry, + tenant_id=tenant_id, + record_id=record_id, + ): + append(_chronology_record(row, selectors=selectors)) # type: ignore[operator] + for row in _rows_for_record( + session, + RecordHoldRevision, + tenant_id=tenant_id, + record_id=record_id, + ): + append(_hold_record(row)) # type: ignore[operator] + for row in _rows_for_record( + session, + RecordDispositionRevision, + tenant_id=tenant_id, + record_id=record_id, + ): + append(_disposition_record(row, selectors=selectors)) # type: ignore[operator] + for row in _rows_for_record( + session, + RecordTransferPackageRevision, + tenant_id=tenant_id, + record_id=record_id, + ): + append(_transfer_record(row)) # type: ignore[operator] + + +def _record_revision(row: RecordRevision) -> DsarRecordRef: + return _record( + "record_revision", + row.id, + "institutional_record_revision", + row.title, + { + "record_id": row.record_id, + "revision": row.revision, + "class_id": row.class_id, + "file_plan_node_id": row.file_plan_node_id, + "title": _bounded_text(row.title, 500), + "description": _bounded_text(row.description, 2_000), + "state": row.state, + "source_authority_mode": row.source_authority_mode, + "access_mode": row.access_mode, + "purpose": _bounded_text(row.purpose, 500), + "classification": _bounded_text(row.classification, 255), + "responsible_unit_id": row.responsible_unit_id, + "responsible_function_id": row.responsible_function_id, + "external_reference_keys": _mapping_keys(row.external_reference), + "valid_from": _iso(row.valid_from), + "valid_to": _iso(row.valid_to), + "recorded_at": _iso(row.recorded_at), + "superseded_at": _iso(row.superseded_at), + "closed_at": _iso(row.closed_at), + "retention_started_at": _iso(row.retention_started_at), + "retention_due_at": _iso(row.retention_due_at), + "appraisal_state": row.appraisal_state, + }, + observed_at=row.recorded_at, + immutable=True, + retention_reason=( + "Record revisions are immutable institutional evidence governed by " + "record-class retention, holds, disposition, and archive obligations." + ), + ) + + +def _current_record_fact(row: RecordRevision) -> DsarRecordRef: + return _record( + "record_current_fact", + row.record_id, + "current_record_lifecycle", + f"Current record state: {row.title}", + { + "record_id": row.record_id, + "revision": row.revision, + "state": row.state, + "classification": _bounded_text(row.classification, 255), + "retention_due_at": _iso(row.retention_due_at), + "appraisal_state": row.appraisal_state, + }, + observed_at=row.recorded_at, + ) + + +def _volume_record(row: RecordVolumeRevision) -> DsarRecordRef: + return _record( + "record_volume_revision", + row.id, + "record_structure_evidence", + row.label, + { + "record_id": row.record_id, + "volume_id": row.volume_id, + "revision": row.revision, + "sequence": row.sequence, + "label": _bounded_text(row.label, 500), + "state": row.state, + "valid_from": _iso(row.valid_from), + "valid_to": _iso(row.valid_to), + "recorded_at": _iso(row.recorded_at), + "superseded_at": _iso(row.superseded_at), + }, + observed_at=row.recorded_at, + immutable=True, + retention_reason="Record-volume revisions are immutable structural evidence.", + ) + + +def _item_record( + row: RecordItem, + *, + selectors: _SubjectSelectors, +) -> DsarRecordRef: + source_matches = any( + reference.module_id == row.source_module + and reference.resource_id == row.resource_id + for reference in selectors.source_references + ) + return _record( + "record_item", + row.id, + "record_filing_evidence", + row.label, + { + "record_id": row.record_id, + "volume_id": row.volume_id, + "sequence": row.sequence, + "source_module": row.source_module, + "resource_type": row.resource_type, + "resource_id": row.resource_id, + "source_revision": row.source_revision, + "source_reference_matches": source_matches, + "label": _bounded_text(row.label, 500), + "relationship": _bounded_text(row.relationship, 255), + "filing_reason": _bounded_text(row.filing_reason, 1_000), + "purpose": _bounded_text(row.purpose, 500), + "authority_mode": row.authority_mode, + "content_type": _bounded_text(row.content_type, 255), + "size_bytes": row.size_bytes, + "source_valid_from": _iso(row.source_valid_from), + "source_valid_to": _iso(row.source_valid_to), + "source_recorded_at": _iso(row.source_recorded_at), + "filed_at": _iso(row.filed_at), + "supersedes_item_id": row.supersedes_item_id, + }, + observed_at=row.filed_at, + immutable=True, + retention_reason=( + "The filing decision and exact source revision are immutable eAkte " + "evidence; source content remains owned by the source module." + ), + ) + + +def _chronology_record( + row: RecordChronologyEntry, + *, + selectors: _SubjectSelectors, +) -> DsarRecordRef: + actor_matches = row.actor_id in selectors.actor_ids + return _record( + "record_chronology_entry", + row.id, + "record_chronology_evidence", + f"Record event {row.event_type}", + { + "record_id": row.record_id, + "event_type": row.event_type, + "record_revision": row.record_revision, + "occurred_at": _iso(row.occurred_at), + "purpose": _bounded_text(row.purpose, 500), + "actor_is_subject": actor_matches, + }, + observed_at=row.occurred_at, + immutable=True, + retention_reason=( + "The record chronology is immutable accountability and lifecycle evidence." + ), + ) + + +def _hold_record(row: RecordHoldRevision) -> DsarRecordRef: + return _record( + "record_hold_revision", + row.id, + "record_hold_evidence", + f"Record hold {row.hold_id}", + { + "record_id": row.record_id, + "hold_id": row.hold_id, + "revision": row.revision, + "status": row.status, + "effective_from": _iso(row.effective_from), + "effective_to": _iso(row.effective_to), + "released_at": _iso(row.released_at), + "recorded_at": _iso(row.recorded_at), + "superseded_at": _iso(row.superseded_at), + }, + observed_at=row.recorded_at, + immutable=True, + retention_reason=( + "Hold history must remain intact to prove why disposition was blocked." + ), + ) + + +def _disposition_record( + row: RecordDispositionRevision, + *, + selectors: _SubjectSelectors, +) -> DsarRecordRef: + return _record( + "record_disposition_revision", + row.id, + "record_disposition_evidence", + f"Record disposition {row.disposition_id}", + { + "record_id": row.record_id, + "disposition_id": row.disposition_id, + "revision": row.revision, + "action": row.action, + "status": row.status, + "subject_revision": row.subject_revision, + "proposed_by_is_subject": row.proposed_by in selectors.actor_ids, + "reviewed_by_is_subject": row.reviewed_by in selectors.actor_ids, + "reviewed_at": _iso(row.reviewed_at), + "recorded_at": _iso(row.recorded_at), + "superseded_at": _iso(row.superseded_at), + }, + observed_at=row.recorded_at, + immutable=True, + retention_reason=( + "Disposition revisions preserve legal, approval, and evidence decisions." + ), + ) + + +def _transfer_record(row: RecordTransferPackageRevision) -> DsarRecordRef: + return _record( + "record_transfer_package_revision", + row.id, + "record_transfer_evidence", + f"Record transfer package {row.package_id}", + { + "record_id": row.record_id, + "package_id": row.package_id, + "disposition_id": row.disposition_id, + "revision": row.revision, + "record_revision": row.record_revision, + "provider_id": row.provider_id, + "profile": _bounded_text(row.profile, 255), + "status": row.status, + "authority_mode": row.authority_mode, + "simulated": row.simulated, + "recorded_at": _iso(row.recorded_at), + "superseded_at": _iso(row.superseded_at), + }, + observed_at=row.recorded_at, + immutable=True, + retention_reason=( + "Transfer-package lifecycle is retained as custody and archive evidence." + ), + ) + + +def _append_operator_attribution( + session: Session, + *, + tenant_id: str, + selectors: _SubjectSelectors, + record_ids: set[str], + append: object, +) -> None: + if not selectors.actor_ids: + return + definitions = ( + (RecordIdentity, "created_by", "created_record", "created_at"), + (RecordRevision, "changed_by", "changed_record_revision", "recorded_at"), + ( + RecordVolumeRevision, + "changed_by", + "changed_record_volume", + "recorded_at", + ), + (RecordItem, "filed_by", "filed_record_item", "filed_at"), + ( + RecordChronologyEntry, + "actor_id", + "performed_record_event", + "occurred_at", + ), + (RecordHoldRevision, "changed_by", "changed_record_hold", "recorded_at"), + ( + RecordDispositionRevision, + "proposed_by", + "proposed_record_disposition", + "recorded_at", + ), + ( + RecordDispositionRevision, + "reviewed_by", + "reviewed_record_disposition", + "reviewed_at", + ), + ( + RecordTransferPackageRevision, + "changed_by", + "changed_record_transfer", + "recorded_at", + ), + ( + RecordFilePlanRevision, + "changed_by", + "changed_file_plan", + "recorded_at", + ), + ( + RecordClassRevision, + "changed_by", + "changed_record_class", + "recorded_at", + ), + ) + for model, actor_field, activity, time_field in definitions: + query = session.query(model).filter( + model.tenant_id == tenant_id, + getattr(model, actor_field).in_(selectors.actor_ids), + ) + if record_ids and hasattr(model, "record_id"): + query = query.filter(model.record_id.in_(record_ids)) + if hasattr(model, "recorded_at"): + query = query.order_by(model.recorded_at, model.id) + elif hasattr(model, "filed_at"): + query = query.order_by(model.filed_at, model.id) + else: + query = query.order_by(model.id) + rows = query.limit(_MAX_RECORDS + 1).all() + if len(rows) > _MAX_RECORDS: + raise ValueError( + "Records DSAR result limit exceeded; narrow the selectors." + ) + for row in rows: + record_id = getattr(row, "record_id", None) + definition_id = getattr(row, "node_id", None) or getattr( + row, + "class_id", + None, + ) + observed_at = getattr(row, time_field) + append( # type: ignore[operator] + _record( + "records_operator_attribution", + f"{activity}:{actor_field}:{row.id}", + "operator_accountability_evidence", + "Records operator attribution", + { + "activity": activity, + "record_id": record_id, + "definition_id": definition_id if record_id is None else None, + "revision": getattr(row, "revision", None), + "observed_at": _iso(observed_at), + }, + observed_at=observed_at, + immutable=True, + retention_reason=( + "Operator attribution is retained as accountability evidence; " + "record contents, reasons, contexts, payloads, hashes, and " + "archive receipts are excluded." + ), + ) + ) + + +def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None: + references = subject.external_references + account_id = _coalesce( + subject.account_id, + references.get("records.account"), + references.get("access.account"), + ) + identity_id = _coalesce( + subject.identity_id, + references.get("records.identity"), + references.get("identity.id"), + ) + membership_id = _coalesce( + subject.membership_id, + references.get("records.membership"), + references.get("tenancy.membership"), + ) + direct_values = { + "record_id": _coalesce( + references.get("records.record"), + references.get("records.record_id"), + ), + "revision_id": _coalesce( + references.get("records.revision"), + references.get("records.record_revision"), + ), + "item_id": _coalesce(references.get("records.item")), + "volume_id": _coalesce(references.get("records.volume")), + "chronology_id": _coalesce(references.get("records.chronology")), + "hold_id": _coalesce(references.get("records.hold")), + "disposition_id": _coalesce(references.get("records.disposition")), + "transfer_package_id": _coalesce(references.get("records.transfer_package")), + } + if ( + account_id is _CONFLICT + or identity_id is _CONFLICT + or membership_id is _CONFLICT + or any(value is _CONFLICT for value in direct_values.values()) + ): + return None + + source_references: list[_SourceReference] = [] + for key, raw_value in sorted(references.items()): + if key in _REFERENCE_ALIASES: + continue + module_id, separator, resource_type = key.partition(".") + value = str(raw_value or "").strip() + if ( + not separator + or module_id not in _SOURCE_MODULES + or not resource_type + or not value + ): + continue + source_references.append( + _SourceReference( + module_id=module_id, + resource_type=resource_type, + resource_id=value, + ) + ) + + return _SubjectSelectors( + account_id=_optional_string(account_id), + identity_id=_optional_string(identity_id), + membership_id=_optional_string(membership_id), + record_id=_optional_string(direct_values["record_id"]), + revision_id=_optional_string(direct_values["revision_id"]), + item_id=_optional_string(direct_values["item_id"]), + volume_id=_optional_string(direct_values["volume_id"]), + chronology_id=_optional_string(direct_values["chronology_id"]), + hold_id=_optional_string(direct_values["hold_id"]), + disposition_id=_optional_string(direct_values["disposition_id"]), + transfer_package_id=_optional_string(direct_values["transfer_package_id"]), + source_references=tuple(source_references), + ) + + +_CONFLICT = object() + + +def _coalesce(*values: str | None) -> str | None | object: + normalized = {str(value).strip() for value in values if str(value or "").strip()} + if len(normalized) > 1: + return _CONFLICT + return next(iter(normalized), None) + + +def _optional_string(value: object) -> str | None: + return value if isinstance(value, str) and value else None + + +def _rows_for_record( + session: Session, + model: type[object], + *, + tenant_id: str, + record_id: str, +) -> list[object]: + return _query( + session, + model, + tenant_id, + (model.record_id == record_id,), # type: ignore[attr-defined] + ) + + +def _query( + session: Session, + model: type[object], + tenant_id: str, + conditions: Sequence[object], +) -> list[object]: + if not conditions: + return [] + query = session.query(model).filter( + model.tenant_id == tenant_id, # type: ignore[attr-defined] + or_(*conditions), + ) + if hasattr(model, "recorded_at"): + query = query.order_by(model.recorded_at, model.id) # type: ignore[attr-defined] + elif hasattr(model, "filed_at"): + query = query.order_by(model.filed_at, model.id) # type: ignore[attr-defined] + else: + query = query.order_by(model.id) # type: ignore[attr-defined] + rows = query.limit(_MAX_RECORDS + 1).all() + if len(rows) > _MAX_RECORDS: + raise ValueError("Records DSAR result limit exceeded; narrow the selectors.") + return rows + + +def _record( + resource_type: str, + resource_id: str, + category: str, + title: str, + data: Mapping[str, object], + *, + observed_at: datetime | None, + immutable: bool = False, + retention_reason: str | None = None, +) -> DsarRecordRef: + return DsarRecordRef( + provider_id="records", + module_id="records", + resource_type=resource_type, + resource_id=str(resource_id), + category=category, + title=_bounded_text(title, 500) or resource_type, + data={key: value for key, value in data.items() if value is not None}, + observed_at=_aware(observed_at), + immutable_evidence=immutable, + retention_reason=retention_reason, + source_path=f"/records?record={data.get('record_id')}" + if data.get("record_id") + else "/records", + ) + + +def _mapping_keys(value: object) -> list[str]: + if not isinstance(value, Mapping): + return [] + return sorted(str(key)[:255] for key in value)[:100] + + +def _bounded_text(value: object, limit: int) -> str | None: + if value is None: + return None + text = str(value) + return text if len(text) <= limit else f"{text[: limit - 1]}…" + + +def _iso(value: datetime | None) -> str | None: + aware = _aware(value) + return aware.isoformat() if aware else None + + +def _aware(value: datetime | None) -> datetime | None: + if value is None or value.tzinfo is not None: + return value + return value.replace(tzinfo=timezone.utc) + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Records DSAR requires a SQLAlchemy Session.") + return value + + +def _validate_record(record: DsarRecordRef) -> None: + if record.provider_id != "records" or record.module_id != "records": + raise ValueError("Records DSAR cannot plan a foreign provider record.") + if not record.resource_type or not record.resource_id: + raise ValueError("Records DSAR record identity is incomplete.") + + +def _validate_action(action: DsarErasureActionRef) -> None: + if action.provider_id != "records" or action.module_id != "records": + raise ValueError("Records DSAR cannot execute a foreign provider action.") + if not action.action_id.startswith("records:"): + raise ValueError("Records DSAR action identity is invalid.") + + +__all__ = ["RECORDS_DSAR_CAPABILITY", "RecordsDsarProvider"] diff --git a/src/govoplan_records/backend/manifest.py b/src/govoplan_records/backend/manifest.py index 8480444..506e8bc 100644 --- a/src/govoplan_records/backend/manifest.py +++ b/src/govoplan_records/backend/manifest.py @@ -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, diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py new file mode 100644 index 0000000..e5a77bb --- /dev/null +++ b/tests/test_dsar_provider.py @@ -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()