From de8ec81c47984219b7f66e447e71286870978da8 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 21 Aug 2026 13:28:07 +0200 Subject: [PATCH] feat(projects): add governed DSAR coverage --- .../backend/dsar_provider.py | 456 ++++++++++++++++++ src/govoplan_projects/backend/manifest.py | 61 ++- tests/test_dsar_provider.py | 213 ++++++++ 3 files changed, 728 insertions(+), 2 deletions(-) create mode 100644 src/govoplan_projects/backend/dsar_provider.py create mode 100644 tests/test_dsar_provider.py diff --git a/src/govoplan_projects/backend/dsar_provider.py b/src/govoplan_projects/backend/dsar_provider.py new file mode 100644 index 0000000..23df6f2 --- /dev/null +++ b/src/govoplan_projects/backend/dsar_provider.py @@ -0,0 +1,456 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Session + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarExecutionResultRef, + DsarRecordRef, + DsarSubjectRef, + dsar_capability_name, +) +from govoplan_projects.backend.db.models import ( + ProjectMembershipGrant, + ProjectObjectEvent, + ProjectObjectIdentity, + ProjectObjectRevision, +) + + +PROJECTS_DSAR_CAPABILITY = dsar_capability_name("projects") +_MAX_RECORDS = 5_000 +_MAX_PERMISSIONS = 100 +_CONFLICT = object() + + +@dataclass(frozen=True, slots=True) +class _Selectors: + actor_ids: tuple[str, ...] + membership_subjects: tuple[tuple[str, str], ...] + object_kind: str | None + object_id: str | None + + +class ProjectsDsarProvider: + provider_id = "projects" + module_id = "projects" + + def search_subject( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + ) -> Sequence[DsarRecordRef]: + db = _session(session) + selectors = _selectors(subject) + if selectors is None: + return () + records: list[DsarRecordRef] = [] + + if selectors.membership_subjects: + membership_conditions = tuple( + and_( + ProjectMembershipGrant.subject_kind == kind, + ProjectMembershipGrant.subject_id == subject_id, + ) + for kind, subject_id in selectors.membership_subjects + ) + query = db.query(ProjectMembershipGrant).filter( + ProjectMembershipGrant.tenant_id == tenant_id, + or_(*membership_conditions), + ) + query = _object_filter(query, ProjectMembershipGrant, selectors) + records.extend( + _membership_record(row) + for row in _limited( + query, + ProjectMembershipGrant.created_at, + ProjectMembershipGrant.id, + label="membership", + ) + ) + + if selectors.actor_ids: + identities = db.query(ProjectObjectIdentity).filter( + ProjectObjectIdentity.tenant_id == tenant_id, + ProjectObjectIdentity.created_by.in_(selectors.actor_ids), + ) + identities = _object_filter(identities, ProjectObjectIdentity, selectors) + records.extend( + _identity_actor_record(row) + for row in _limited( + identities, + ProjectObjectIdentity.created_at, + ProjectObjectIdentity.id, + label="identity attribution", + ) + ) + + revisions = db.query(ProjectObjectRevision).filter( + ProjectObjectRevision.tenant_id == tenant_id, + ProjectObjectRevision.changed_by.in_(selectors.actor_ids), + ) + revisions = _object_filter(revisions, ProjectObjectRevision, selectors) + records.extend( + _revision_actor_record(row) + for row in _limited( + revisions, + ProjectObjectRevision.recorded_at, + ProjectObjectRevision.id, + label="revision attribution", + ) + ) + + events = db.query(ProjectObjectEvent).filter( + ProjectObjectEvent.tenant_id == tenant_id, + ProjectObjectEvent.actor_id.in_(selectors.actor_ids), + ) + events = _object_filter(events, ProjectObjectEvent, selectors) + records.extend( + _event_actor_record(row) + for row in _limited( + events, + ProjectObjectEvent.occurred_at, + ProjectObjectEvent.id, + label="event attribution", + ) + ) + + if len(records) > _MAX_RECORDS: + raise ValueError("Projects DSAR result limit exceeded; narrow 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 _selectors(subject) is None: + raise ValueError("Projects DSAR subject selectors conflict.") + actions = [] + for record in records: + _validate_record(record) + membership = record.resource_type == "project_membership" + actions.append( + DsarErasureActionRef( + action_id=( + f"projects:{'manual_review' if membership else 'retain'}:" + f"{record.resource_type}:{record.resource_id}" + ), + provider_id=self.provider_id, + module_id=self.module_id, + kind="manual_review" if membership else "retain", + resource_type=record.resource_type, + resource_id=record.resource_id, + title=("Review " if membership else "Retain ") + record.title, + rationale=( + "Membership removal must preserve access continuity, project " + "ownership, and retained decision history." + if membership + else record.retention_reason + or "Project lifecycle attribution remains governance 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 _selectors(subject) is None: + raise ValueError("Projects DSAR subject selectors conflict.") + results = [] + for action in actions: + _validate_action(action) + if action.executable or action.kind not in {"manual_review", "retain"}: + raise ValueError("Projects DSAR publishes non-executable actions only.") + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="blocked", + summary=( + "Project membership remains unchanged pending access and " + "ownership review." + if action.kind == "manual_review" + else "Project lifecycle attribution remains governance evidence." + ), + evidence={"request_id": request_id}, + ) + ) + return tuple(results) + + +def _selectors(subject: DsarSubjectRef) -> _Selectors | None: + references = subject.external_references + values = { + "account_id": _coalesce( + subject.account_id, + references.get("projects.account"), + references.get("access.account"), + ), + "identity_id": _coalesce( + subject.identity_id, + references.get("projects.identity"), + references.get("identity.id"), + ), + "membership_id": _coalesce( + subject.membership_id, + references.get("projects.membership"), + references.get("tenancy.membership"), + ), + "function_assignment_id": _coalesce( + references.get("projects.function_assignment"), + references.get("projects.function_assignment_id"), + ), + "project_id": _coalesce( + references.get("projects.project"), references.get("projects.project_id") + ), + "portfolio_id": _coalesce( + references.get("projects.portfolio"), + references.get("projects.portfolio_id"), + ), + "milestone_id": _coalesce( + references.get("projects.milestone"), + references.get("projects.milestone_id"), + ), + } + if any(value is _CONFLICT for value in values.values()): + return None + object_values = tuple( + (kind, value) + for kind, value in ( + ("project", _optional(values["project_id"])), + ("portfolio", _optional(values["portfolio_id"])), + ("milestone", _optional(values["milestone_id"])), + ) + if value + ) + if len(object_values) > 1: + return None + account_id = _optional(values["account_id"]) + identity_id = _optional(values["identity_id"]) + membership_id = _optional(values["membership_id"]) + function_assignment_id = _optional(values["function_assignment_id"]) + actor_ids = tuple( + dict.fromkeys( + value for value in (account_id, identity_id, membership_id) if value + ) + ) + membership_subjects = tuple( + item + for item in ( + ("account", account_id) if account_id else None, + ("identity", identity_id) if identity_id else None, + ( + ("function_assignment", function_assignment_id) + if function_assignment_id + else None + ), + ) + if item is not None + ) + if not actor_ids and not membership_subjects: + return None + return _Selectors( + actor_ids=actor_ids, + membership_subjects=membership_subjects, + object_kind=object_values[0][0] if object_values else None, + object_id=object_values[0][1] if object_values else None, + ) + + +def _object_filter(query, model, selectors: _Selectors): + if selectors.object_kind: + query = query.filter(model.object_kind == selectors.object_kind) + if selectors.object_id: + query = query.filter(model.object_id == selectors.object_id) + return query + + +def _membership_record(row: ProjectMembershipGrant) -> DsarRecordRef: + permissions = row.permissions + if not isinstance(permissions, list) or len(permissions) > _MAX_PERMISSIONS: + raise ValueError("Projects DSAR membership permissions exceed their bound.") + return DsarRecordRef( + provider_id="projects", + module_id="projects", + resource_type="project_membership", + resource_id=row.id, + category="project_participation", + title="Project membership", + data={ + "membership_grant_id": row.id, + "object_kind": row.object_kind, + "object_id": row.object_id, + "subject_kind": row.subject_kind, + "subject_id": row.subject_id, + "role": row.role, + "permissions": [str(item)[:120] for item in permissions], + "active": row.active, + "source_revision": row.source_revision, + "created_at": _iso(row.created_at), + "updated_at": _iso(row.updated_at), + }, + observed_at=_aware(row.updated_at), + retention_reason=( + "Membership removal requires project access, ownership, and history review." + ), + ) + + +def _identity_actor_record(row: ProjectObjectIdentity) -> DsarRecordRef: + return _attribution_record( + resource_type="project_identity_actor_attribution", + resource_id=row.id, + title="Project identity actor attribution", + data={ + "object_kind": row.object_kind, + "object_id": row.object_id, + "activity": "created_project_identity", + "created_at": _iso(row.created_at), + }, + observed_at=row.created_at, + ) + + +def _revision_actor_record(row: ProjectObjectRevision) -> DsarRecordRef: + return _attribution_record( + resource_type="project_revision_actor_attribution", + resource_id=row.id, + title="Project revision actor attribution", + data={ + "object_kind": row.object_kind, + "object_id": row.object_id, + "revision_id": row.id, + "revision": row.revision, + "state": row.state, + "visibility": row.visibility, + "recorded_at": _iso(row.recorded_at), + "superseded_at": _iso(row.superseded_at), + "activity": "recorded_project_revision", + }, + observed_at=row.recorded_at, + ) + + +def _event_actor_record(row: ProjectObjectEvent) -> DsarRecordRef: + return _attribution_record( + resource_type="project_event_actor_attribution", + resource_id=row.id, + title="Project event actor 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=row.occurred_at, + ) + + +def _attribution_record( + *, + resource_type: str, + resource_id: str, + title: str, + data: dict[str, object], + observed_at: datetime | None, +) -> DsarRecordRef: + return DsarRecordRef( + provider_id="projects", + module_id="projects", + resource_type=resource_type, + resource_id=resource_id, + category="project_governance_attribution", + title=title, + data=data, + observed_at=_aware(observed_at), + immutable_evidence=True, + retention_reason=( + "Project lifecycle attribution is retained for governance and accountability." + ), + ) + + +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"Projects 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(value: object) -> str | None: + return value if isinstance(value, str) and value 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("Projects DSAR requires a SQLAlchemy Session.") + return value + + +_RESOURCE_TYPES = { + "project_membership", + "project_identity_actor_attribution", + "project_revision_actor_attribution", + "project_event_actor_attribution", +} + + +def _validate_record(record: DsarRecordRef) -> None: + if record.provider_id != "projects" or record.module_id != "projects": + raise ValueError("Projects DSAR cannot plan a foreign provider record.") + if record.resource_type not in _RESOURCE_TYPES or not record.resource_id: + raise ValueError("Projects DSAR record identity is invalid.") + + +def _validate_action(action: DsarErasureActionRef) -> None: + if action.provider_id != "projects" or action.module_id != "projects": + raise ValueError("Projects DSAR cannot execute a foreign provider action.") + if not action.action_id.startswith("projects:"): + raise ValueError("Projects DSAR action identity is invalid.") + + +__all__ = ["PROJECTS_DSAR_CAPABILITY", "ProjectsDsarProvider"] diff --git a/src/govoplan_projects/backend/manifest.py b/src/govoplan_projects/backend/manifest.py index 50799cd..a405106 100644 --- a/src/govoplan_projects/backend/manifest.py +++ b/src/govoplan_projects/backend/manifest.py @@ -37,6 +37,10 @@ from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base from govoplan_projects.backend.acl import ProjectScopeAclProvider from govoplan_projects.backend.db import models as project_models +from govoplan_projects.backend.dsar_provider import ( + PROJECTS_DSAR_CAPABILITY, + ProjectsDsarProvider, +) from govoplan_projects.backend.search_source import create_projects_search_source from govoplan_projects.backend.service import ( CAPABILITY_PROJECTS_REGISTRY, @@ -130,6 +134,10 @@ def _registry(context: ModuleContext) -> SqlProjectRegistry: return SqlProjectRegistry() +def _dsar_provider(_context: ModuleContext) -> ProjectsDsarProvider: + return ProjectsDsarProvider() + + def _tenant_summary(session, tenant_id: str) -> dict[str, int]: counts = { kind: count @@ -339,8 +347,12 @@ manifest = ModuleManifest( ), provides_interfaces=( ModuleInterfaceProvider(name="projects.registry", version="0.1.0"), + ModuleInterfaceProvider(name=PROJECTS_DSAR_CAPABILITY, version="0.1.0"), ), - capability_factories={CAPABILITY_PROJECTS_REGISTRY: _registry}, + capability_factories={ + CAPABILITY_PROJECTS_REGISTRY: _registry, + PROJECTS_DSAR_CAPABILITY: _dsar_provider, + }, capability_documentation={ CAPABILITY_PROJECTS_REGISTRY: CapabilityDocumentation( label="Projects registry", @@ -350,6 +362,14 @@ manifest = ModuleManifest( ), contract_version="0.1.0", ), + PROJECTS_DSAR_CAPABILITY: CapabilityDocumentation( + label="Projects data-subject request provider", + summary=( + "Exports exact project memberships and minimized lifecycle attribution " + "without project payload content." + ), + contract_version="0.1.0", + ), }, search_sources=( SearchSourceProviderRegistration( @@ -389,7 +409,44 @@ manifest = ModuleManifest( ProjectScopeAclProvider("milestone"), ), tenant_summary_providers=(_tenant_summary,), - documentation=DOCUMENTATION, + documentation=( + DocumentationTopic( + id="projects.data-subject-requests", + title="Project data-subject requests", + summary=( + "Export project participation and accountable activity without project content." + ), + body=( + "Projects correlates exact account and identity membership grants, plus " + "an explicitly supplied function-assignment reference. It separately " + "matches exact account, identity, and membership identifiers used for " + "creation and lifecycle attribution. Searches can narrow to one project, " + "portfolio, or milestone, but an object identifier alone never establishes " + "a subject. Membership exports include role, permissions, active state, " + "and source revision. Titles, search text, planning payloads, event payloads, " + "request hashes, idempotency keys, and unrelated members are excluded. " + "Membership removal requires manual access and ownership review; immutable " + "creation, revision, and event attribution remains retained governance evidence." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "auditor"), + related_modules=("core", "access", "tasks", "audit"), + order=90, + metadata={ + "help_contexts": [ + "projects.workspace", + "privacy.data-subject-requests", + ], + "consequence_classes": { + "export_project_membership": "Returns exact subject grants without unrelated members.", + "review_membership_removal": "Requires ownership and access-continuity review.", + "retain_project_attribution": "Preserves immutable lifecycle accountability.", + }, + }, + ), + *DOCUMENTATION, + ), architecture=ARCHITECTURE, ) diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py new file mode 100644 index 0000000..89b4c46 --- /dev/null +++ b/tests/test_dsar_provider.py @@ -0,0 +1,213 @@ +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_projects.backend.db.models import ( + ProjectMembershipGrant, + ProjectObjectEvent, + ProjectObjectIdentity, + ProjectObjectRevision, +) +from govoplan_projects.backend.dsar_provider import ( + PROJECTS_DSAR_CAPABILITY, + ProjectsDsarProvider, +) +from govoplan_projects.backend.manifest import manifest + + +NOW = datetime(2026, 8, 22, 15, 0, tzinfo=UTC) + + +class ProjectsDsarProviderTests(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 = ProjectsDsarProvider() + self._seed() + self.session.commit() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def _seed(self) -> None: + self.session.add_all( + ( + ProjectObjectIdentity( + id="identity-row-1", + tenant_id="tenant-1", + object_kind="project", + object_id="project-1", + object_key="secret-object-key-do-not-export", + created_by="account-1", + created_at=NOW, + updated_at=NOW, + ), + ProjectObjectRevision( + id="revision-1", + tenant_id="tenant-1", + identity_id="identity-row-1", + object_kind="project", + object_id="project-1", + revision=2, + state="active", + title="Sensitive project title do not export", + visibility="restricted", + recorded_at=NOW, + search_text="project-search-content-do-not-export", + payload={"secret": "project-payload-do-not-export"}, + changed_by="account-1", + created_at=NOW, + updated_at=NOW, + ), + ProjectMembershipGrant( + id="membership-1", + tenant_id="tenant-1", + object_kind="project", + object_id="project-1", + subject_kind="account", + subject_id="account-1", + role="member", + permissions=["read", "write"], + active=True, + source_revision=2, + created_at=NOW, + updated_at=NOW, + ), + ProjectMembershipGrant( + id="membership-other-person", + tenant_id="tenant-1", + object_kind="project", + object_id="project-1", + subject_kind="account", + subject_id="account-other-do-not-export", + role="owner", + permissions=["admin"], + active=True, + source_revision=2, + ), + ProjectObjectEvent( + id="event-row-1", + tenant_id="tenant-1", + object_kind="project", + object_id="project-1", + object_revision=2, + event_id="event-1", + event_type="projects.project.updated", + 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"}, + created_at=NOW, + updated_at=NOW, + ), + ProjectMembershipGrant( + id="membership-other-tenant", + tenant_id="tenant-2", + object_kind="project", + object_id="project-other", + subject_kind="account", + subject_id="account-1", + role="member", + permissions=["read"], + active=True, + source_revision=1, + ), + ) + ) + + def test_search_exports_membership_and_minimized_attribution(self) -> None: + self.assertIsInstance(self.provider, DsarProvider) + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef(account_id="account-1"), + ) + self.assertEqual( + { + "project_membership", + "project_identity_actor_attribution", + "project_revision_actor_attribution", + "project_event_actor_attribution", + }, + {record.resource_type for record in records}, + ) + exported = json.dumps([record.to_dict() for record in records]) + self.assertIn("membership-1", exported) + for excluded in ( + "account-other-do-not-export", + "secret-object-key-do-not-export", + "Sensitive project title do not export", + "project-search-content-do-not-export", + "project-payload-do-not-export", + "event-idempotency-do-not-export", + "event-request-hash-do-not-export", + "event-payload-do-not-export", + ): + self.assertNotIn(excluded, exported) + + def test_requires_subject_identity_and_enforces_narrowing(self) -> None: + self.assertEqual( + (), + self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef(email="member@example.test"), + ), + ) + narrowed = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + account_id="account-1", + external_references={"projects.project": "project-1"}, + ), + ) + self.assertEqual(4, len(narrowed)) + conflict = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + account_id="account-1", + external_references={ + "projects.project": "project-1", + "projects.portfolio": "portfolio-1", + }, + ), + ) + self.assertEqual((), conflict) + + def test_membership_requires_review_and_attribution_is_retained(self) -> None: + subject = DsarSubjectRef(account_id="account-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, + ) + by_type = {action.resource_type: action.kind for action in actions} + self.assertEqual("manual_review", by_type["project_membership"]) + self.assertEqual("retain", by_type["project_event_actor_attribution"]) + + def test_manifest_registers_provider_and_documentation(self) -> None: + self.assertIn(PROJECTS_DSAR_CAPABILITY, manifest.capability_factories) + self.assertIn( + "projects.data-subject-requests", + {topic.id for topic in manifest.documentation}, + ) + + +if __name__ == "__main__": + unittest.main()