diff --git a/src/govoplan_decisions/backend/dsar_provider.py b/src/govoplan_decisions/backend/dsar_provider.py new file mode 100644 index 0000000..65013c0 --- /dev/null +++ b/src/govoplan_decisions/backend/dsar_provider.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarExecutionResultRef, + DsarRecordRef, + DsarSubjectRef, + dsar_capability_name, +) +from govoplan_decisions.backend.db.models import FormalDecisionRevision + + +DECISIONS_DSAR_CAPABILITY = dsar_capability_name("decisions") +_MAX_RECORDS = 5_000 +_CONFLICT = object() + + +@dataclass(frozen=True, slots=True) +class _SubjectSelectors: + actor_ids: tuple[str, ...] + decision_id: str | None + + +class DecisionsDsarProvider: + provider_id = "decisions" + module_id = "decisions" + + def search_subject( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + ) -> Sequence[DsarRecordRef]: + db = _session(session) + selectors = _subject_selectors(subject) + if selectors is None: + return () + query = db.query(FormalDecisionRevision).filter( + FormalDecisionRevision.tenant_id == tenant_id, + FormalDecisionRevision.created_by.in_(selectors.actor_ids), + ) + if selectors.decision_id: + query = query.filter( + FormalDecisionRevision.decision_id == selectors.decision_id + ) + rows = ( + query.order_by( + FormalDecisionRevision.recorded_at, + FormalDecisionRevision.id, + ) + .limit(_MAX_RECORDS + 1) + .all() + ) + if len(rows) > _MAX_RECORDS: + raise ValueError( + "Decisions DSAR result limit exceeded; narrow the selectors." + ) + return tuple(_decision_attribution(row) for row in rows) + + 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("Decisions DSAR subject selectors conflict.") + actions: list[DsarErasureActionRef] = [] + for record in records: + _validate_record(record) + actions.append( + DsarErasureActionRef( + action_id=( + f"decisions:retain:formal_decision_actor_attribution:" + f"{record.resource_id}" + ), + provider_id=self.provider_id, + module_id=self.module_id, + kind="retain", + resource_type=record.resource_type, + resource_id=record.resource_id, + title=f"Retain {record.title}", + rationale=record.retention_reason + or "Formal Decision attribution is immutable evidence.", + 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("Decisions DSAR subject selectors conflict.") + results: list[DsarExecutionResultRef] = [] + for action in actions: + _validate_action(action) + if action.executable or action.kind != "retain": + raise ValueError("Decisions DSAR publishes retain-only actions.") + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="blocked", + summary=( + "Formal Decision attribution remains immutable institutional " + "and legal evidence." + ), + evidence={"request_id": request_id}, + ) + ) + return tuple(results) + + +def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None: + references = subject.external_references + values = { + "account_id": _coalesce( + subject.account_id, + references.get("decisions.account"), + references.get("access.account"), + ), + "membership_id": _coalesce( + subject.membership_id, + references.get("decisions.membership"), + references.get("tenancy.membership"), + ), + "identity_id": _coalesce( + subject.identity_id, + references.get("decisions.identity"), + references.get("identity.id"), + ), + "actor_id": _coalesce( + references.get("decisions.actor"), + references.get("decisions.created_by"), + ), + "decision_id": _coalesce( + references.get("decisions.decision"), + references.get("decisions.decision_id"), + ), + } + if any(value is _CONFLICT for value in values.values()): + return None + actor_ids = tuple( + dict.fromkeys( + value + for value in ( + _optional_string(values["account_id"]), + _prefixed("account", values["account_id"]), + _optional_string(values["membership_id"]), + _prefixed("membership", values["membership_id"]), + _optional_string(values["identity_id"]), + _prefixed("identity", values["identity_id"]), + ) + if value + ) + ) + direct_actor = _optional_string(values["actor_id"]) + if direct_actor: + if actor_ids and direct_actor not in actor_ids: + return None + if not actor_ids: + actor_ids = (direct_actor,) + if not actor_ids: + return None + return _SubjectSelectors( + actor_ids=actor_ids, + decision_id=_optional_string(values["decision_id"]), + ) + + +def _decision_attribution(row: FormalDecisionRevision) -> DsarRecordRef: + return DsarRecordRef( + provider_id="decisions", + module_id="decisions", + resource_type="formal_decision_actor_attribution", + resource_id=row.id, + category="formal_decision_accountability", + title="Formal Decision creator attribution", + data={ + "decision_id": row.decision_id, + "revision": row.revision, + "decision_type": row.decision_type, + "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), + "activity": "recorded_formal_decision_revision", + }, + observed_at=_aware(row.recorded_at), + immutable_evidence=True, + retention_reason=( + "Formal Decision creator attribution is immutable institutional and " + "legal accountability evidence." + ), + ) + + +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 _prefixed(prefix: str, value: object) -> str | None: + normalized = _optional_string(value) + return f"{prefix}:{normalized}" if normalized else None + + +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("Decisions DSAR requires a SQLAlchemy Session.") + return value + + +def _validate_record(record: DsarRecordRef) -> None: + if record.provider_id != "decisions" or record.module_id != "decisions": + raise ValueError("Decisions DSAR cannot plan a foreign provider record.") + if ( + record.resource_type != "formal_decision_actor_attribution" + or not record.resource_id + ): + raise ValueError("Decisions DSAR record identity is invalid.") + + +def _validate_action(action: DsarErasureActionRef) -> None: + if action.provider_id != "decisions" or action.module_id != "decisions": + raise ValueError("Decisions DSAR cannot execute a foreign provider action.") + if not action.action_id.startswith("decisions:retain:"): + raise ValueError("Decisions DSAR action identity is invalid.") + + +__all__ = ["DECISIONS_DSAR_CAPABILITY", "DecisionsDsarProvider"] diff --git a/src/govoplan_decisions/backend/manifest.py b/src/govoplan_decisions/backend/manifest.py index b2def9f..15a13e7 100644 --- a/src/govoplan_decisions/backend/manifest.py +++ b/src/govoplan_decisions/backend/manifest.py @@ -22,6 +22,10 @@ from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.db.base import Base from govoplan_decisions.backend.db import models as decision_models +from govoplan_decisions.backend.dsar_provider import ( + DECISIONS_DSAR_CAPABILITY, + DecisionsDsarProvider, +) from govoplan_decisions.backend.record_source import ( CAPABILITY_RECORD_SOURCE_DECISIONS, create_decisions_record_source, @@ -63,6 +67,10 @@ def _registry(_context: ModuleContext) -> SqlDecisionRegistry: return SqlDecisionRegistry() +def _dsar_provider(_context: ModuleContext) -> DecisionsDsarProvider: + return DecisionsDsarProvider() + + manifest = ModuleManifest( id=MODULE_ID, name=MODULE_NAME, @@ -85,6 +93,7 @@ manifest = ModuleManifest( ModuleInterfaceProvider( name=CAPABILITY_RECORD_SOURCE_DECISIONS, version="1.0.0" ), + ModuleInterfaceProvider(name=DECISIONS_DSAR_CAPABILITY, version="0.1.0"), ), permissions=( _permission( @@ -126,6 +135,7 @@ manifest = ModuleManifest( capability_factories={ CAPABILITY_DECISION_REGISTRY: _registry, CAPABILITY_RECORD_SOURCE_DECISIONS: create_decisions_record_source, + DECISIONS_DSAR_CAPABILITY: _dsar_provider, }, capability_documentation={ CAPABILITY_DECISION_REGISTRY: CapabilityDocumentation( @@ -138,6 +148,14 @@ manifest = ModuleManifest( summary="Resolves complete, currently authorized immutable Decision revisions for Records filing.", contract_version="1.0.0", ), + DECISIONS_DSAR_CAPABILITY: CapabilityDocumentation( + label="Decisions data-subject request provider", + summary=( + "Exports minimized formal-Decision creator attribution without " + "protected reasoning, outcomes, conditions, or payloads." + ), + contract_version="0.1.0", + ), }, migration_spec=MigrationSpec( module_id=MODULE_ID, @@ -161,6 +179,40 @@ manifest = ModuleManifest( ), ), documentation=( + DocumentationTopic( + id="decisions.data-subject-requests", + title="Formal Decision data-subject requests", + summary=( + "Export minimized creator attribution from immutable formal Decision " + "revisions without exposing protected Decision content." + ), + body=( + "Decisions searches exact account, membership, identity, or explicit " + "actor identifiers inside the active tenant. An optional Decision " + "identifier only narrows that verified actor search and cannot disclose " + "a Decision by itself. The access record contains revision, type, state, " + "valid-time, recorded-time, and creator activity only. Protected " + "reasoning, operative results, conditions, evidence payloads, and " + "digests remain excluded. Formal Decision history is immutable legal " + "and institutional evidence, so the provider publishes retain-only " + "erasure outcomes and never rewrites a revision." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "auditor"), + related_modules=("core", "approvals", "committee", "records"), + metadata={ + "help_contexts": ["privacy.data-subject-requests"], + "consequence_classes": { + "export_creator_attribution": ( + "Returns lifecycle and creator context without protected payloads." + ), + "retain_formal_history": ( + "Preserves immutable institutional and legal evidence." + ), + }, + }, + ), DocumentationTopic( id="decisions.formal-outcome", title="Formal institutional Decisions", diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py new file mode 100644 index 0000000..3fd19ec --- /dev/null +++ b/tests/test_dsar_provider.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import json +import unittest +from datetime import UTC, datetime + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.dsar import DsarProvider, 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_decisions.backend.db.models import FormalDecisionRevision +from govoplan_decisions.backend.dsar_provider import ( + DECISIONS_DSAR_CAPABILITY, + DecisionsDsarProvider, +) +from govoplan_decisions.backend.manifest import manifest + + +NOW = datetime(2026, 8, 21, 14, 0, tzinfo=UTC) + + +class _Registry: + def __init__(self, provider: DecisionsDsarProvider) -> None: + self.provider = provider + + def capability_names(self): + return (DECISIONS_DSAR_CAPABILITY,) + + def capability_owner(self, name): + if name != DECISIONS_DSAR_CAPABILITY: + raise KeyError(name) + return "decisions" + + def tenant_entitlement_resolver(self): + class _Resolver: + @staticmethod + def resolve(session, tenant_id): + del session, tenant_id + return type("State", (), {"effective_modules": ("decisions",)})() + + return _Resolver() + + def require_tenant_capability(self, name, session, **kwargs): + del session, kwargs + if name != DECISIONS_DSAR_CAPABILITY: + raise KeyError(name) + return self.provider + + def manifests(self): + return (type("Manifest", (), {"id": "decisions"})(),) + + +class DecisionsDsarProviderTests(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 = DecisionsDsarProvider() + self.assertIsInstance(self.provider, DsarProvider) + self.session.add_all( + ( + self._decision( + "revision-1", decision_id="decision-1", created_by="account-1" + ), + self._decision( + "revision-other", + decision_id="decision-other", + created_by="account-other", + ), + self._decision( + "revision-other-tenant", + tenant_id="tenant-2", + decision_id="decision-other-tenant", + created_by="account-1", + ), + ) + ) + self.session.commit() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + @staticmethod + def _decision( + row_id: str, + *, + decision_id: str, + created_by: str, + tenant_id: str = "tenant-1", + ) -> FormalDecisionRevision: + return FormalDecisionRevision( + id=row_id, + tenant_id=tenant_id, + decision_id=decision_id, + revision="1", + decision_type="permit", + state="effective", + valid_from=NOW, + recorded_at=NOW, + payload={ + "reasoning": f"protected-reasoning-{row_id}-do-not-export", + "operative_result": f"protected-result-{row_id}-do-not-export", + "digest": f"payload-digest-{row_id}-do-not-export", + }, + created_by=created_by, + ) + + @staticmethod + def _subject() -> DsarSubjectRef: + return DsarSubjectRef(account_id="account-1") + + def test_search_exports_minimized_creator_attribution(self) -> None: + records = self.provider.search_subject( + self.session, tenant_id="tenant-1", subject=self._subject() + ) + self.assertEqual(["revision-1"], [record.resource_id for record in records]) + exported = json.dumps(records[0].to_dict()) + self.assertIn("decision-1", exported) + self.assertIn("recorded_formal_decision_revision", exported) + self.assertNotIn("protected-reasoning", exported) + self.assertNotIn("protected-result", exported) + self.assertNotIn("payload-digest", exported) + self.assertNotIn("decision-other", exported) + + def test_decision_narrowing_and_actor_conflicts_fail_closed(self) -> None: + narrowed = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + account_id="account-1", + external_references={"decisions.decision": "decision-1"}, + ), + ) + conflict = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + account_id="account-1", + external_references={"decisions.actor": "account-other"}, + ), + ) + decision_only = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + external_references={"decisions.decision": "decision-1"} + ), + ) + self.assertEqual(["revision-1"], [record.resource_id for record in narrowed]) + self.assertEqual((), conflict) + self.assertEqual((), decision_only) + + def test_erasure_is_retain_only(self) -> None: + records = self.provider.search_subject( + self.session, tenant_id="tenant-1", subject=self._subject() + ) + actions = self.provider.plan_erasure( + self.session, + tenant_id="tenant-1", + subject=self._subject(), + records=records, + ) + self.assertEqual(["retain"], [action.kind for action in actions]) + results = self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=self._subject(), + actions=actions, + request_id="dsar-decisions-1", + ) + self.assertEqual(["blocked"], [result.status for result in results]) + self.assertEqual(3, self.session.query(FormalDecisionRevision).count()) + + def test_manifest_and_core_workflow_discover_provider(self) -> None: + self.assertIn(DECISIONS_DSAR_CAPABILITY, manifest.capability_factories) + row = create_data_subject_request( + self.session, + tenant_id="tenant-1", + reference="DSAR-DECISIONS-1", + request_kind="access", + subject=self._subject(), + purpose="Decision attribution access request", + legal_basis=None, + due_at=None, + requested_by_account_id="operator-1", + ) + search_data_subject_request( + self.session, + registry=_Registry(self.provider), + row=row, + expected_revision=row.resource_revision, + ) + self.assertEqual("searched", row.status) + self.assertEqual(1, row.search_result["record_count"]) + + +if __name__ == "__main__": + unittest.main()