From 049ea80385bc5490f514bfed67488c7bfcc47ccc Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 21 Aug 2026 12:16:00 +0200 Subject: [PATCH] feat(committee): add governed DSAR coverage --- .../backend/dsar_provider.py | 396 ++++++++++++++++++ src/govoplan_committee/backend/manifest.py | 60 ++- tests/test_dsar_provider.py | 258 ++++++++++++ 3 files changed, 713 insertions(+), 1 deletion(-) create mode 100644 src/govoplan_committee/backend/dsar_provider.py create mode 100644 tests/test_dsar_provider.py diff --git a/src/govoplan_committee/backend/dsar_provider.py b/src/govoplan_committee/backend/dsar_provider.py new file mode 100644 index 0000000..d07cb03 --- /dev/null +++ b/src/govoplan_committee/backend/dsar_provider.py @@ -0,0 +1,396 @@ +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_committee.backend.db.models import ( + CommitteeDecisionProjection, + CommitteeWorkspaceEvent, + CommitteeWorkspaceRevision, +) +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarExecutionResultRef, + DsarRecordRef, + DsarSubjectRef, + dsar_capability_name, +) + + +COMMITTEE_DSAR_CAPABILITY = dsar_capability_name("committee") +_MAX_RECORDS = 5_000 +_CONFLICT = object() + + +@dataclass(frozen=True, slots=True) +class _SubjectSelectors: + actor_ids: tuple[str, ...] + object_kind: str | None + object_id: str | None + decision_id: str | None + + +class CommitteeDsarProvider: + provider_id = "committee" + module_id = "committee" + + 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 () + records: list[DsarRecordRef] = [] + revisions = db.query(CommitteeWorkspaceRevision).filter( + CommitteeWorkspaceRevision.tenant_id == tenant_id, + CommitteeWorkspaceRevision.changed_by.in_(selectors.actor_ids), + ) + events = db.query(CommitteeWorkspaceEvent).filter( + CommitteeWorkspaceEvent.tenant_id == tenant_id, + CommitteeWorkspaceEvent.actor_id.in_(selectors.actor_ids), + ) + decisions = db.query(CommitteeDecisionProjection).filter( + CommitteeDecisionProjection.tenant_id == tenant_id, + CommitteeDecisionProjection.changed_by.in_(selectors.actor_ids), + ) + if selectors.object_kind: + revisions = revisions.filter( + CommitteeWorkspaceRevision.object_kind == selectors.object_kind + ) + events = events.filter( + CommitteeWorkspaceEvent.object_kind == selectors.object_kind + ) + if selectors.object_id: + revisions = revisions.filter( + CommitteeWorkspaceRevision.object_id == selectors.object_id + ) + events = events.filter( + CommitteeWorkspaceEvent.object_id == selectors.object_id + ) + if selectors.object_kind == "meeting": + decisions = decisions.filter( + CommitteeDecisionProjection.meeting_id == selectors.object_id + ) + elif selectors.object_kind in {"agenda", "agenda_item"}: + decisions = decisions.filter( + CommitteeDecisionProjection.agenda_item_id == selectors.object_id + ) + elif selectors.object_kind == "decision": + decisions = decisions.filter( + CommitteeDecisionProjection.decision_id == selectors.object_id + ) + if selectors.decision_id: + decisions = decisions.filter( + CommitteeDecisionProjection.decision_id == selectors.decision_id + ) + + records.extend( + _revision_attribution(row) + for row in _limited( + revisions, + CommitteeWorkspaceRevision.recorded_at, + CommitteeWorkspaceRevision.id, + label="workspace revision attribution", + ) + ) + records.extend( + _event_attribution(row) + for row in _limited( + events, + CommitteeWorkspaceEvent.occurred_at, + CommitteeWorkspaceEvent.id, + label="workspace event attribution", + ) + ) + records.extend( + _decision_attribution(row) + for row in _limited( + decisions, + CommitteeDecisionProjection.recorded_at, + CommitteeDecisionProjection.id, + label="Decision projection attribution", + ) + ) + if len(records) > _MAX_RECORDS: + raise ValueError( + "Committee DSAR combined result limit exceeded; narrow the selectors." + ) + return tuple( + sorted(records, key=lambda item: (item.resource_type, item.resource_id)) + ) + + 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("Committee DSAR subject selectors conflict.") + actions: list[DsarErasureActionRef] = [] + for record in records: + _validate_record(record) + actions.append( + DsarErasureActionRef( + action_id=( + f"committee:retain:{record.resource_type}:{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 "Committee 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("Committee DSAR subject selectors conflict.") + results: list[DsarExecutionResultRef] = [] + for action in actions: + _validate_action(action) + if action.executable or action.kind != "retain": + raise ValueError("Committee DSAR publishes retain-only actions.") + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="blocked", + summary=( + "Committee workspace, event, and Decision attribution remains " + "immutable institutional 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("committee.account"), + references.get("access.account"), + ), + "membership_id": _coalesce( + subject.membership_id, + references.get("committee.membership"), + references.get("tenancy.membership"), + ), + "identity_id": _coalesce( + subject.identity_id, + references.get("committee.identity"), + references.get("identity.id"), + ), + "actor_id": _coalesce( + references.get("committee.actor"), + references.get("committee.changed_by"), + ), + "object_kind": _coalesce( + references.get("committee.object_kind"), + references.get("committee.kind"), + ), + "object_id": _coalesce( + references.get("committee.object"), + references.get("committee.object_id"), + ), + "decision_id": _coalesce( + references.get("committee.decision"), + references.get("committee.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, + object_kind=_optional_string(values["object_kind"]), + object_id=_optional_string(values["object_id"]), + decision_id=_optional_string(values["decision_id"]), + ) + + +def _revision_attribution(row: CommitteeWorkspaceRevision) -> DsarRecordRef: + return DsarRecordRef( + provider_id="committee", + module_id="committee", + resource_type="committee_workspace_actor_attribution", + resource_id=row.id, + category="committee_workspace_accountability", + title="Committee workspace revision attribution", + data={ + "object_kind": row.object_kind, + "object_id": row.object_id, + "revision": row.revision, + "parent_kind": row.parent_kind, + "parent_id": row.parent_id, + "state": row.state, + "recorded_at": _iso(row.recorded_at), + "superseded_at": _iso(row.superseded_at), + "activity": "recorded_workspace_revision", + }, + observed_at=_aware(row.recorded_at), + immutable_evidence=True, + retention_reason="Committee workspace attribution is immutable evidence.", + ) + + +def _event_attribution(row: CommitteeWorkspaceEvent) -> DsarRecordRef: + return DsarRecordRef( + provider_id="committee", + module_id="committee", + resource_type="committee_event_actor_attribution", + resource_id=row.id, + category="committee_workspace_accountability", + title="Committee lifecycle event attribution", + data={ + "object_kind": row.object_kind, + "object_id": row.object_id, + "object_revision": row.object_revision, + "event_id": row.event_id, + "event_type": row.event_type, + "occurred_at": _iso(row.occurred_at), + }, + observed_at=_aware(row.occurred_at), + immutable_evidence=True, + retention_reason="Committee event attribution is immutable evidence.", + ) + + +def _decision_attribution(row: CommitteeDecisionProjection) -> DsarRecordRef: + return DsarRecordRef( + provider_id="committee", + module_id="committee", + resource_type="committee_decision_actor_attribution", + resource_id=row.id, + category="committee_decision_accountability", + title="Committee fallback Decision attribution", + data={ + "decision_id": row.decision_id, + "revision": row.revision, + "meeting_id": row.meeting_id, + "agenda_item_id": row.agenda_item_id, + "state": row.state, + "recorded_at": _iso(row.recorded_at), + "superseded_at": _iso(row.superseded_at), + "activity": "recorded_decision_projection", + }, + observed_at=_aware(row.recorded_at), + immutable_evidence=True, + retention_reason=( + "Committee fallback Decision attribution is immutable evidence." + ), + ) + + +def _limited(query, first, second, *, label: str): + rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all() + if len(rows) > _MAX_RECORDS: + raise ValueError(f"Committee DSAR {label} limit exceeded; narrow selectors.") + return rows + + +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("Committee DSAR requires a SQLAlchemy Session.") + return value + + +_RESOURCE_TYPES = { + "committee_workspace_actor_attribution", + "committee_event_actor_attribution", + "committee_decision_actor_attribution", +} + + +def _validate_record(record: DsarRecordRef) -> None: + if record.provider_id != "committee" or record.module_id != "committee": + raise ValueError("Committee DSAR cannot plan a foreign provider record.") + if record.resource_type not in _RESOURCE_TYPES or not record.resource_id: + raise ValueError("Committee DSAR record identity is invalid.") + + +def _validate_action(action: DsarErasureActionRef) -> None: + if action.provider_id != "committee" or action.module_id != "committee": + raise ValueError("Committee DSAR cannot execute a foreign provider action.") + if not action.action_id.startswith("committee:retain:"): + raise ValueError("Committee DSAR action identity is invalid.") + + +__all__ = ["COMMITTEE_DSAR_CAPABILITY", "CommitteeDsarProvider"] diff --git a/src/govoplan_committee/backend/manifest.py b/src/govoplan_committee/backend/manifest.py index 142e63a..35c3f67 100644 --- a/src/govoplan_committee/backend/manifest.py +++ b/src/govoplan_committee/backend/manifest.py @@ -46,6 +46,10 @@ from govoplan_committee.backend.ballots import ( CommitteeBallotFinalizer, ) from govoplan_committee.backend.db import models as committee_models +from govoplan_committee.backend.dsar_provider import ( + COMMITTEE_DSAR_CAPABILITY, + CommitteeDsarProvider, +) from govoplan_committee.backend.workspace import ( CAPABILITY_COMMITTEE_WORKSPACE, SqlCommitteeWorkspace, @@ -124,6 +128,10 @@ def _ballot_finalizer(context: ModuleContext) -> CommitteeBallotFinalizer: return CommitteeBallotFinalizer(context.registry) +def _dsar_provider(_context: ModuleContext) -> CommitteeDsarProvider: + return CommitteeDsarProvider() + + def _router(context: ModuleContext): from govoplan_committee.backend.router import configure_registry, router @@ -399,6 +407,7 @@ manifest = ModuleManifest( name=CAPABILITY_COMMITTEE_BALLOT_FINALIZER, version="0.1.0", ), + ModuleInterfaceProvider(name=COMMITTEE_DSAR_CAPABILITY, version="0.1.0"), ), requires_interfaces=( ModuleInterfaceRequirement( @@ -430,6 +439,7 @@ manifest = ModuleManifest( CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path, CAPABILITY_COMMITTEE_WORKSPACE: _workspace, CAPABILITY_COMMITTEE_BALLOT_FINALIZER: _ballot_finalizer, + COMMITTEE_DSAR_CAPABILITY: _dsar_provider, }, capability_documentation={ CAPABILITY_COMMITTEE_DECISION_PATH: CapabilityDocumentation( @@ -447,6 +457,14 @@ manifest = ModuleManifest( summary="Imports aggregate result evidence from provider-neutral ballot adapters without persisting individual ballots.", contract_version="0.1.0", ), + COMMITTEE_DSAR_CAPABILITY: CapabilityDocumentation( + label="Committee data-subject request provider", + summary=( + "Exports minimized workspace, event, and fallback-Decision actor " + "attribution without deliberation or minute payloads." + ), + contract_version="0.1.0", + ), }, migration_spec=MigrationSpec( module_id=MODULE_ID, @@ -472,7 +490,47 @@ manifest = ModuleManifest( tenant_summary_providers=(_tenant_summary,), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, - documentation=DOCUMENTATION, + documentation=( + DocumentationTopic( + id="committee.data-subject-requests", + title="Committee data-subject requests", + summary=( + "Export minimized actor attribution from immutable Committee records " + "without disclosing deliberation or ballot content." + ), + body=( + "Committee correlates exact account, membership, identity, or explicit " + "actor identifiers inside the active tenant. Optional object-kind, " + "object, or Decision identifiers only narrow an already verified actor " + "search. Workspace results contain stable object, parent, revision, " + "state, and timing context. Event and fallback-Decision results contain " + "only lifecycle attribution and stable meeting or agenda references. " + "Search text, minutes, deliberation payloads, individual ballots, " + "request hashes, idempotency values, and referenced provider records " + "are excluded and never traversed. All returned attribution is retained " + "as immutable institutional evidence." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "auditor"), + related_modules=("core", "decisions", "voting", "records"), + metadata={ + "help_contexts": [ + "committee.workspace", + "privacy.data-subject-requests", + ], + "consequence_classes": { + "export_actor_attribution": ( + "Returns stable lifecycle context without institutional payloads." + ), + "retain_committee_history": ( + "Preserves immutable meeting and Decision accountability evidence." + ), + }, + }, + ), + *DOCUMENTATION, + ), architecture=ARCHITECTURE, ) diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py new file mode 100644 index 0000000..a9e8ebd --- /dev/null +++ b/tests/test_dsar_provider.py @@ -0,0 +1,258 @@ +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_committee.backend.db.models import ( + CommitteeDecisionProjection, + CommitteeWorkspaceEvent, + CommitteeWorkspaceRevision, +) +from govoplan_committee.backend.dsar_provider import ( + COMMITTEE_DSAR_CAPABILITY, + CommitteeDsarProvider, +) +from govoplan_committee.backend.manifest import manifest +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, +) + + +NOW = datetime(2026, 8, 21, 17, 0, tzinfo=UTC) + + +class _Registry: + def __init__(self, provider: CommitteeDsarProvider) -> None: + self.provider = provider + + def capability_names(self): + return (COMMITTEE_DSAR_CAPABILITY,) + + def capability_owner(self, name): + if name != COMMITTEE_DSAR_CAPABILITY: + raise KeyError(name) + return "committee" + + def tenant_entitlement_resolver(self): + class _Resolver: + @staticmethod + def resolve(session, tenant_id): + del session, tenant_id + return type("State", (), {"effective_modules": ("committee",)})() + + return _Resolver() + + def require_tenant_capability(self, name, session, **kwargs): + del session, kwargs + if name != COMMITTEE_DSAR_CAPABILITY: + raise KeyError(name) + return self.provider + + def manifests(self): + return (type("Manifest", (), {"id": "committee"})(),) + + +class CommitteeDsarProviderTests(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 = CommitteeDsarProvider() + self.assertIsInstance(self.provider, DsarProvider) + self._seed() + self.session.commit() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def _seed(self) -> None: + self.session.add_all( + ( + CommitteeWorkspaceRevision( + id="workspace-revision-1", + tenant_id="tenant-1", + object_kind="meeting", + object_id="meeting-1", + revision=1, + parent_kind="body", + parent_id="body-1", + state="scheduled", + title="Private meeting title do not export", + search_text="private search text do not export", + recorded_at=NOW, + payload={"minutes": "private minutes do not export"}, + changed_by="account-1", + ), + CommitteeWorkspaceRevision( + id="workspace-revision-other", + tenant_id="tenant-1", + object_kind="meeting", + object_id="meeting-other", + revision=1, + state="scheduled", + title="Other meeting", + search_text="other search text", + recorded_at=NOW, + payload={"private": "other payload"}, + changed_by="account-other", + ), + ) + ) + self.session.add( + CommitteeWorkspaceEvent( + id="event-1", + tenant_id="tenant-1", + object_kind="meeting", + object_id="meeting-1", + object_revision=1, + event_id="event-external-1", + event_type="meeting.scheduled", + occurred_at=NOW, + actor_id="account-1", + idempotency_key="event-idempotency-do-not-export", + request_sha256="event-request-hash-do-not-export", + payload={"secret": "event-payload-do-not-export"}, + ) + ) + self.session.add( + CommitteeDecisionProjection( + id="decision-projection-1", + tenant_id="tenant-1", + decision_id="decision-1", + revision="1", + meeting_id="meeting-1", + agenda_item_id="agenda-1", + state="effective", + recorded_at=NOW, + payload={ + "reasoning": "decision-reasoning-do-not-export", + "ballot": "individual-ballot-do-not-export", + }, + changed_by="account-1", + ) + ) + + @staticmethod + def _subject() -> DsarSubjectRef: + return DsarSubjectRef(account_id="account-1") + + def test_search_exports_minimized_committee_attribution(self) -> None: + records = self.provider.search_subject( + self.session, tenant_id="tenant-1", subject=self._subject() + ) + self.assertEqual( + { + "committee_workspace_actor_attribution", + "committee_event_actor_attribution", + "committee_decision_actor_attribution", + }, + {record.resource_type for record in records}, + ) + exported = json.dumps([record.to_dict() for record in records]) + self.assertIn("meeting-1", exported) + self.assertIn("agenda-1", exported) + for excluded in ( + "Private meeting title do not export", + "private search text do not export", + "private minutes do not export", + "event-idempotency-do-not-export", + "event-request-hash-do-not-export", + "event-payload-do-not-export", + "decision-reasoning-do-not-export", + "individual-ballot-do-not-export", + "meeting-other", + ): + self.assertNotIn(excluded, exported) + + def test_object_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={ + "committee.object_kind": "meeting", + "committee.object": "meeting-1", + }, + ), + ) + conflict = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + account_id="account-1", + external_references={"committee.actor": "account-other"}, + ), + ) + object_only = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + external_references={"committee.object": "meeting-1"} + ), + ) + self.assertEqual( + { + "committee_workspace_actor_attribution", + "committee_event_actor_attribution", + "committee_decision_actor_attribution", + }, + {record.resource_type for record in narrowed}, + ) + self.assertEqual((), conflict) + self.assertEqual((), object_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.assertTrue(all(action.kind == "retain" for action in actions)) + results = self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=self._subject(), + actions=actions, + request_id="dsar-committee-1", + ) + self.assertTrue(all(result.status == "blocked" for result in results)) + self.assertEqual(2, self.session.query(CommitteeWorkspaceRevision).count()) + + def test_manifest_and_core_workflow_discover_provider(self) -> None: + self.assertIn(COMMITTEE_DSAR_CAPABILITY, manifest.capability_factories) + row = create_data_subject_request( + self.session, + tenant_id="tenant-1", + reference="DSAR-COMMITTEE-1", + request_kind="access", + subject=self._subject(), + purpose="Committee 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(3, row.search_result["record_count"]) + + +if __name__ == "__main__": + unittest.main()