feat(projects): add governed DSAR coverage

This commit is contained in:
2026-08-21 13:28:07 +02:00
parent 4bebb4211a
commit de8ec81c47
3 changed files with 728 additions and 2 deletions
@@ -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"]
+59 -2
View File
@@ -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,
)
+213
View File
@@ -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()