feat(projects): add governed DSAR coverage
This commit is contained in:
@@ -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"]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user