From 65ff14a6137987a52dc5582e1197450bdeaa1ad0 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 21 Aug 2026 01:32:50 +0200 Subject: [PATCH] feat(idm): add governed DSAR coverage --- README.md | 9 + src/govoplan_idm/backend/dsar_provider.py | 749 ++++++++++++++++++++++ src/govoplan_idm/backend/manifest.py | 92 +++ tests/test_dsar_provider.py | 560 ++++++++++++++++ 4 files changed, 1410 insertions(+) create mode 100644 src/govoplan_idm/backend/dsar_provider.py create mode 100644 tests/test_dsar_provider.py diff --git a/README.md b/README.md index cfc8fce..3658bbb 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,15 @@ planning. The WebUI exposed by this repository is a normal module UI at `/idm`. It is the editing surface for identity-to-organization-function assignment links. +The module also publishes `privacy.dsar.idm`. The provider finds tenant-scoped +function assignments, typed relationships, governed assignment changes, and +lifecycle events using corroborated identity/account selectors. Automated +exports minimize other candidates and actors and exclude opaque settings, +properties, provenance, external source references, policy/workflow internals, +idempotency material, evidence payloads, comments, and event details. Assignment +or relationship changes require the normal governed IDM lifecycle; immutable +change and event evidence is retained with an explicit reason. + Its interface archetypes, consequence classes, contextual-help contract, and accessibility evidence are recorded in [`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md). diff --git a/src/govoplan_idm/backend/dsar_provider.py b/src/govoplan_idm/backend/dsar_provider.py new file mode 100644 index 0000000..6ede4e9 --- /dev/null +++ b/src/govoplan_idm/backend/dsar_provider.py @@ -0,0 +1,749 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarExecutionResultRef, + DsarRecordRef, + DsarSubjectRef, + dsar_capability_name, +) +from govoplan_idm.backend.db.models import ( + IdmFunctionAssignmentChange, + IdmFunctionAssignmentChangeEvent, + IdmIdentityRelationship, + IdmOrganizationFunctionAssignment, + IdmTypedGroup, +) + + +IDM_DSAR_CAPABILITY = dsar_capability_name("idm") +_MAX_RECORDS = 5_000 + + +@dataclass(frozen=True, slots=True) +class _SubjectSelectors: + account_id: str | None + identity_id: str | None + references: Mapping[str, str] + + @property + def has_canonical_selector(self) -> bool: + return bool(self.account_id or self.identity_id) + + +class IdmDsarProvider: + provider_id = "idm" + module_id = "idm" + + 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 () + + assignments = _matching_assignments( + db, + tenant_id=tenant_id, + selectors=selectors, + ) + relationships = _matching_relationships( + db, + tenant_id=tenant_id, + selectors=selectors, + ) + changes = _matching_changes( + db, + tenant_id=tenant_id, + selectors=selectors, + ) + if _direct_reference_conflicts( + selectors, + assignments=assignments, + relationships=relationships, + changes=changes, + ): + return () + + records: list[DsarRecordRef] = [] + seen: set[tuple[str, str]] = set() + + def append(record: DsarRecordRef) -> None: + key = (record.resource_type, record.resource_id) + if key in seen: + return + if len(records) >= _MAX_RECORDS: + raise ValueError( + "IDM DSAR result limit exceeded; narrow the subject selectors." + ) + seen.add(key) + records.append(record) + + for assignment in assignments: + match_fields = _assignment_match_fields(assignment, selectors) + append( + _record( + "idm_function_assignment", + assignment.id, + "institutional_function_fact", + "IDM organization-function assignment", + { + "match_fields": match_fields, + "identity_id": ( + assignment.identity_id + if "identity_id" in match_fields + else None + ), + "account_id": ( + assignment.account_id + if "account_id" in match_fields + else None + ), + "function_id": assignment.function_id, + "organization_unit_id": assignment.organization_unit_id, + "applies_to_subunits": assignment.applies_to_subunits, + "source": assignment.source, + "has_delegated_source": bool( + assignment.delegated_from_assignment_id + ), + "acting_for_account_id": ( + assignment.acting_for_account_id + if "acting_for_account_id" in match_fields + else None + ), + "valid_from": _iso(assignment.valid_from), + "valid_until": _iso(assignment.valid_until), + "expired_event_at": _iso(assignment.expired_event_at), + "is_active": assignment.is_active, + }, + observed_at=assignment.updated_at, + ) + ) + + group_ids = { + row.target_group_id + for row in relationships + if row.target_group_id is not None + } + groups = { + row.id: row + for row in _rows_by_ids( + db, + IdmTypedGroup, + tenant_id=tenant_id, + ids=group_ids, + ) + } + for group in groups.values(): + append( + _record( + "idm_typed_group_context", + group.id, + "typed_relationship_context", + "IDM typed-group relationship context", + { + "key": group.key, + "name": _bounded_text(group.name, 255), + "group_type": group.group_type, + "status": group.status, + "source_provider": group.source_provider, + "revision": group.revision, + }, + observed_at=group.updated_at, + immutable=True, + retention_reason=( + "The minimized typed-group definition is retained as context " + "for the subject's effective-dated relationship evidence." + ), + ) + ) + for relationship in relationships: + match_fields = _relationship_match_fields(relationship, selectors) + group = groups.get(relationship.target_group_id or "") + append( + _record( + "idm_identity_relationship", + relationship.id, + "typed_identity_relationship", + "IDM typed identity relationship", + { + "match_fields": match_fields, + "relationship_kind": relationship.relationship_kind, + "subject_identity_id": ( + relationship.subject_identity_id + if "subject_identity_id" in match_fields + else None + ), + "target_group_id": relationship.target_group_id, + "target_group_key": group.key if group else None, + "target_group_name": _bounded_text( + group.name if group else None, + 255, + ), + "target_group_type": group.group_type if group else None, + "related_identity_id": ( + relationship.related_identity_id + if "related_identity_id" in match_fields + else None + ), + "role": _bounded_text(relationship.role, 120), + "valid_from": _iso(relationship.valid_from), + "valid_until": _iso(relationship.valid_until), + "status": relationship.status, + "revoked_at": _iso(relationship.revoked_at), + "revoked_by": ( + relationship.revoked_by + if relationship.revoked_by == selectors.account_id + else None + ), + "revocation_reason": _bounded_text( + relationship.revocation_reason, + 2_000, + ), + "expired_event_at": _iso(relationship.expired_event_at), + "source_provider": relationship.source_provider, + "revision": relationship.revision, + }, + observed_at=relationship.updated_at, + ) + ) + + change_ids: set[str] = set() + for change in changes: + change_ids.add(change.id) + match_fields = _change_match_fields(change, selectors) + append( + _record( + "idm_function_assignment_change", + change.id, + "function_assignment_governance_evidence", + "IDM governed function-assignment change", + { + "match_fields": match_fields, + "kind": change.kind, + "state": change.state, + "profile": change.profile, + "function_id": change.function_id, + "organization_unit_id": change.organization_unit_id, + "candidate_identity_id": ( + change.candidate_identity_id + if "candidate_identity_id" in match_fields + else None + ), + "candidate_account_id": ( + change.candidate_account_id + if "candidate_account_id" in match_fields + else None + ), + "initiator_account_id": ( + change.initiator_account_id + if "initiator_account_id" in match_fields + else None + ), + "initiator_identity_id": ( + change.initiator_identity_id + if "initiator_identity_id" in match_fields + else None + ), + "has_represented_assignment": bool( + change.represented_assignment_id + ), + "requested_valid_from": _iso(change.requested_valid_from), + "requested_valid_until": _iso(change.requested_valid_until), + "applies_to_subunits": change.applies_to_subunits, + "assignment_source": change.assignment_source, + "resulting_assignment_id": change.resulting_assignment_id, + "expires_at": _iso(change.expires_at), + "resource_revision": change.resource_revision, + }, + observed_at=change.updated_at, + immutable=True, + retention_reason=( + "Governed assignment requests and grants retain their state, " + "subject linkage, and outcome as institutional decision evidence." + ), + ) + ) + + for event in _matching_change_events( + db, + tenant_id=tenant_id, + selectors=selectors, + change_ids=change_ids, + ): + match_fields = [] + if event.change_id in change_ids: + match_fields.append("change_id") + if event.actor_account_id == selectors.account_id: + match_fields.append("actor_account_id") + if event.actor_identity_id == selectors.identity_id: + match_fields.append("actor_identity_id") + append( + _record( + "idm_function_assignment_change_event", + event.id, + "function_assignment_governance_evidence", + "IDM function-assignment lifecycle event", + { + "match_fields": match_fields, + "change_id": event.change_id, + "sequence": event.sequence, + "action": event.action, + "from_state": event.from_state, + "to_state": event.to_state, + "actor_account_id": ( + event.actor_account_id + if "actor_account_id" in match_fields + else None + ), + "actor_identity_id": ( + event.actor_identity_id + if "actor_identity_id" in match_fields + else None + ), + "actor_assignment_id": ( + event.actor_assignment_id + if ( + "actor_account_id" in match_fields + or "actor_identity_id" in match_fields + ) + else None + ), + "created_at": _iso(event.created_at), + }, + observed_at=event.created_at, + immutable=True, + retention_reason=( + "Assignment lifecycle events are immutable decision and " + "accountability evidence." + ), + ) + ) + + return tuple(records) + + 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("IDM DSAR subject selectors conflict.") + actions: list[DsarErasureActionRef] = [] + for record in records: + _validate_record(record) + if record.immutable_evidence: + kind = "retain" + rationale = record.retention_reason or ( + "IDM governance evidence must be retained." + ) + title = f"Retain {record.title}" + else: + kind = "manual_review" + rationale = ( + "Function assignments and typed relationships are effective-dated " + "institutional facts. An authorized IDM operator must correct, " + "revoke, deactivate, or expire them through the governed lifecycle " + "after reviewing organizational and third-party consequences." + ) + title = f"Review {record.title}" + actions.append( + DsarErasureActionRef( + action_id=f"idm:{kind}:{record.resource_type}:{record.resource_id}", + provider_id=self.provider_id, + module_id=self.module_id, + kind=kind, + resource_type=record.resource_type, + resource_id=record.resource_id, + title=title, + rationale=rationale, + 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("IDM DSAR subject selectors conflict.") + results: list[DsarExecutionResultRef] = [] + for action in actions: + _validate_action(action) + if action.executable: + raise ValueError( + "IDM DSAR does not publish executable erasure actions." + ) + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="blocked", + summary=( + "Use the governed IDM assignment or relationship lifecycle " + "after organizational, evidence, and third-party review." + ), + evidence={"request_id": request_id}, + ) + ) + return tuple(results) + + +def _matching_assignments( + session: Session, + *, + tenant_id: str, + selectors: _SubjectSelectors, +) -> list[IdmOrganizationFunctionAssignment]: + conditions = [] + if selectors.identity_id: + conditions.append( + IdmOrganizationFunctionAssignment.identity_id == selectors.identity_id + ) + if selectors.account_id: + conditions.extend( + ( + IdmOrganizationFunctionAssignment.account_id == selectors.account_id, + IdmOrganizationFunctionAssignment.acting_for_account_id + == selectors.account_id, + ) + ) + if reference := selectors.references.get("assignment"): + conditions.append(IdmOrganizationFunctionAssignment.id == reference) + return _query_conditions( + session, + IdmOrganizationFunctionAssignment, + tenant_id=tenant_id, + conditions=conditions, + ) + + +def _matching_relationships( + session: Session, + *, + tenant_id: str, + selectors: _SubjectSelectors, +) -> list[IdmIdentityRelationship]: + conditions = [] + if selectors.identity_id: + conditions.extend( + ( + IdmIdentityRelationship.subject_identity_id == selectors.identity_id, + IdmIdentityRelationship.related_identity_id == selectors.identity_id, + ) + ) + if reference := selectors.references.get("relationship"): + conditions.append(IdmIdentityRelationship.id == reference) + return _query_conditions( + session, + IdmIdentityRelationship, + tenant_id=tenant_id, + conditions=conditions, + ) + + +def _matching_changes( + session: Session, + *, + tenant_id: str, + selectors: _SubjectSelectors, +) -> list[IdmFunctionAssignmentChange]: + conditions = [] + if selectors.identity_id: + conditions.extend( + ( + IdmFunctionAssignmentChange.candidate_identity_id + == selectors.identity_id, + IdmFunctionAssignmentChange.initiator_identity_id + == selectors.identity_id, + ) + ) + if selectors.account_id: + conditions.extend( + ( + IdmFunctionAssignmentChange.candidate_account_id + == selectors.account_id, + IdmFunctionAssignmentChange.initiator_account_id + == selectors.account_id, + ) + ) + if reference := selectors.references.get("assignment_change"): + conditions.append(IdmFunctionAssignmentChange.id == reference) + return _query_conditions( + session, + IdmFunctionAssignmentChange, + tenant_id=tenant_id, + conditions=conditions, + ) + + +def _matching_change_events( + session: Session, + *, + tenant_id: str, + selectors: _SubjectSelectors, + change_ids: set[str], +) -> list[IdmFunctionAssignmentChangeEvent]: + conditions = [] + if change_ids: + conditions.append(IdmFunctionAssignmentChangeEvent.change_id.in_(change_ids)) + if selectors.account_id: + conditions.append( + IdmFunctionAssignmentChangeEvent.actor_account_id == selectors.account_id + ) + if selectors.identity_id: + conditions.append( + IdmFunctionAssignmentChangeEvent.actor_identity_id == selectors.identity_id + ) + return _query_conditions( + session, + IdmFunctionAssignmentChangeEvent, + tenant_id=tenant_id, + conditions=conditions, + ) + + +def _direct_reference_conflicts( + selectors: _SubjectSelectors, + *, + assignments: Sequence[IdmOrganizationFunctionAssignment], + relationships: Sequence[IdmIdentityRelationship], + changes: Sequence[IdmFunctionAssignmentChange], +) -> bool: + if not selectors.has_canonical_selector: + return False + checks = ( + ( + "assignment", + assignments, + lambda row: _assignment_match_fields(row, selectors), + ), + ( + "relationship", + relationships, + lambda row: _relationship_match_fields(row, selectors), + ), + ( + "assignment_change", + changes, + lambda row: _change_match_fields(row, selectors), + ), + ) + for kind, rows, match in checks: + reference = selectors.references.get(kind) + if not reference: + continue + row = next((item for item in rows if item.id == reference), None) + if row is None or not [field for field in match(row) if field != "reference"]: + return True + return False + + +def _assignment_match_fields( + row: IdmOrganizationFunctionAssignment, + selectors: _SubjectSelectors, +) -> list[str]: + fields = [] + if row.identity_id == selectors.identity_id: + fields.append("identity_id") + if selectors.account_id and row.account_id == selectors.account_id: + fields.append("account_id") + if selectors.account_id and row.acting_for_account_id == selectors.account_id: + fields.append("acting_for_account_id") + if row.id == selectors.references.get("assignment"): + fields.append("reference") + return fields + + +def _relationship_match_fields( + row: IdmIdentityRelationship, + selectors: _SubjectSelectors, +) -> list[str]: + fields = [] + if row.subject_identity_id == selectors.identity_id: + fields.append("subject_identity_id") + if selectors.identity_id and row.related_identity_id == selectors.identity_id: + fields.append("related_identity_id") + if row.revoked_by == selectors.account_id: + fields.append("revoked_by") + if row.id == selectors.references.get("relationship"): + fields.append("reference") + return fields + + +def _change_match_fields( + row: IdmFunctionAssignmentChange, + selectors: _SubjectSelectors, +) -> list[str]: + fields = [] + for field, expected in ( + ("candidate_identity_id", selectors.identity_id), + ("candidate_account_id", selectors.account_id), + ("initiator_identity_id", selectors.identity_id), + ("initiator_account_id", selectors.account_id), + ): + if expected and getattr(row, field) == expected: + fields.append(field) + if row.id == selectors.references.get("assignment_change"): + fields.append("reference") + return fields + + +def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None: + groups = { + "account_id": ( + subject.account_id, + subject.external_references.get("idm.account"), + subject.external_references.get("access.account"), + ), + "identity_id": ( + subject.identity_id, + subject.external_references.get("idm.identity"), + subject.external_references.get("identity.id"), + ), + } + normalized: dict[str, str | None] = {} + for key, values in groups.items(): + distinct = {value for item in values if (value := _normalized_id(item))} + if len(distinct) > 1: + return None + normalized[key] = next(iter(distinct), None) + + aliases = { + "idm.assignment": "assignment", + "idm.relationship": "relationship", + "idm.assignment_change": "assignment_change", + } + references = { + target: value + for source, target in aliases.items() + if (value := _normalized_id(subject.external_references.get(source))) + } + return _SubjectSelectors(references=references, **normalized) + + +def _rows_by_ids( + session: Session, + model: type, + *, + tenant_id: str, + ids: set[str], +) -> list[object]: + if not ids: + return [] + return _bounded_rows( + session.query(model) + .filter(model.tenant_id == tenant_id, model.id.in_(ids)) + .order_by(model.id) + ) + + +def _query_conditions( + session: Session, + model: type, + *, + tenant_id: str, + conditions: Sequence[object], +) -> list[object]: + if not conditions: + return [] + return _bounded_rows( + session.query(model) + .filter(model.tenant_id == tenant_id, or_(*conditions)) + .order_by(model.id) + ) + + +def _validate_record(record: DsarRecordRef) -> None: + if record.provider_id != "idm" or record.module_id != "idm": + raise ValueError("IDM DSAR received a foreign provider record.") + + +def _validate_action(action: DsarErasureActionRef) -> None: + if action.provider_id != "idm" or action.module_id != "idm": + raise ValueError("IDM DSAR received a foreign provider action.") + + +def _record( + resource_type: str, + resource_id: str, + category: str, + title: str, + data: Mapping[str, object], + *, + observed_at: datetime | None, + immutable: bool = False, + retention_reason: str | None = None, +) -> DsarRecordRef: + return DsarRecordRef( + provider_id="idm", + module_id="idm", + resource_type=resource_type, + resource_id=resource_id, + category=category, + title=title, + data=data, + observed_at=observed_at, + immutable_evidence=immutable, + retention_reason=retention_reason, + source_path="/idm", + ) + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("IDM DSAR provider requires a SQLAlchemy session.") + return value + + +def _bounded_rows(query: object) -> list[object]: + rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined] + if len(rows) > _MAX_RECORDS: + raise ValueError("IDM DSAR match limit exceeded; narrow the subject selectors.") + return rows + + +def _bounded_text(value: str | None, limit: int) -> str | None: + return value[:limit] if value else None + + +def _normalized_id(value: object) -> str | None: + if value is None: + return None + value = str(value).strip() + return value or None + + +def _iso(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.isoformat() + + +__all__ = ["IDM_DSAR_CAPABILITY", "IdmDsarProvider"] diff --git a/src/govoplan_idm/backend/manifest.py b/src/govoplan_idm/backend/manifest.py index 8cdfb8a..b682380 100644 --- a/src/govoplan_idm/backend/manifest.py +++ b/src/govoplan_idm/backend/manifest.py @@ -29,6 +29,7 @@ from govoplan_core.core.organizations import ( from govoplan_core.core.views import ViewSurface from govoplan_core.core.module_guards import persistent_table_uninstall_guard from govoplan_core.core.modules import ( + CapabilityDocumentation, DocumentationCondition, DocumentationLink, DocumentationTopic, @@ -48,6 +49,7 @@ from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.db.base import Base from govoplan_idm.backend.db import models as idm_models # noqa: F401 - populate metadata +from govoplan_idm.backend.dsar_provider import IDM_DSAR_CAPABILITY from govoplan_idm.backend.workflow_definitions import ( function_assignment_workflow_definitions, ) @@ -217,6 +219,13 @@ def _relationship_directory(context: ModuleContext) -> object: return SqlIdmRelationshipDirectory(identities=identities) +def _idm_dsar_provider(context: ModuleContext) -> object: + del context + from govoplan_idm.backend.dsar_provider import IdmDsarProvider + + return IdmDsarProvider() + + manifest = ModuleManifest( id="idm", name="IDM", @@ -259,6 +268,10 @@ manifest = ModuleManifest( name="idm.function_assignment_changes", version="1.0.0", ), + ModuleInterfaceProvider( + name=IDM_DSAR_CAPABILITY, + version="0.1.0", + ), ), requires_interfaces=( ModuleInterfaceRequirement( @@ -325,11 +338,90 @@ manifest = ModuleManifest( CAPABILITY_IDM_DIRECTORY: _idm_directory, CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory, CAPABILITY_IDM_RELATIONSHIPS: _relationship_directory, + IDM_DSAR_CAPABILITY: _idm_dsar_provider, + }, + capability_documentation={ + IDM_DSAR_CAPABILITY: CapabilityDocumentation( + label="IDM data-subject request provider", + summary=( + "Finds tenant-scoped function assignments, typed relationships, " + "governed changes, and lifecycle evidence with third-party and opaque " + "payload minimization." + ), + contract_version="0.1.0", + documentation_types=("admin",), + audience=("privacy_officer", "idm_admin", "records_manager"), + ), }, workflow_definitions=function_assignment_workflow_definitions( module_version=MODULE_VERSION, ), documentation=( + DocumentationTopic( + id="idm.privacy.data-subject-requests", + title="Review IDM data in a data-subject request", + summary=( + "Collect tenant-scoped institutional function and relationship facts " + "while preserving governed decision evidence." + ), + body=( + "IDM searches corroborated account and identity selectors plus " + "namespaced assignment, relationship, and assignment-change references. " + "Results include effective-dated organization-function assignments, typed " + "identity relationships with minimized group context, governed assignment " + "requests or grants, and related lifecycle events. When a record concerns " + "another candidate or actor, their identity and account identifiers are " + "removed from the automated export. Settings, group properties, external " + "source references, provenance, justifications, evidence arrays, policy " + "decisions, workflow internals, idempotency keys, request digests, opaque " + "metadata, event comments and details, unrelated records, and other tenants " + "are excluded. Assignments and relationships are effective institutional " + "facts, so correction, revocation, deactivation, or expiry requires an " + "authorized IDM lifecycle review. Governed change and event records retain " + "explicit decision-evidence reasons. Identity owns the person record, " + "Organizations owns functions and units, and Access owns the authority " + "derived from accepted IDM facts." + ), + layer="static", + documentation_types=("admin",), + audience=( + "privacy_officer", + "idm_admin", + "records_manager", + "operator", + ), + related_modules=( + "access", + "audit", + "identity", + "organizations", + "records", + ), + conditions=( + DocumentationCondition(required_modules=("idm", "access")), + ), + links=( + DocumentationLink( + label="Data-subject requests", + href="/admin?section=tenant-data-subject-requests", + kind="runtime", + ), + DocumentationLink( + label="IDM assignments", + href="/idm", + kind="runtime", + ), + ), + metadata={ + "kind": "guide", + "help_contexts": [ + "idm.route.assignments", + "idm.action.view-function-assignments", + "idm.function-change.request", + ], + }, + order=24, + ), DocumentationTopic( id="idm.search.directory", title="Search authorized IDM records", diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py new file mode 100644 index 0000000..de530b2 --- /dev/null +++ b/tests/test_dsar_provider.py @@ -0,0 +1,560 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timezone + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarProvider, + DsarSubjectRef, +) +from govoplan_core.db.base import Base +from govoplan_core.privacy.dsar_workflow import ( + create_data_subject_request, + search_data_subject_request, +) +from govoplan_identity.backend.db.models import CanonicalIdentity +from govoplan_idm.backend.db.models import ( + IdmFunctionAssignmentChange, + IdmFunctionAssignmentChangeEvent, + IdmIdentityRelationship, + IdmOrganizationFunctionAssignment, + IdmTypedGroup, +) +from govoplan_idm.backend.dsar_provider import IDM_DSAR_CAPABILITY, IdmDsarProvider +from govoplan_idm.backend.manifest import manifest +from govoplan_organizations.backend.db.models import ( + OrganizationFunction, + OrganizationUnit, +) + + +class _Registry: + def __init__(self, provider: IdmDsarProvider, *, idm_active: bool = True) -> None: + self.provider = provider + self.idm_active = idm_active + + def capability_names(self): + return (IDM_DSAR_CAPABILITY,) + + def capability_owner(self, name): + self._assert_capability(name) + return "idm" + + def tenant_entitlement_resolver(self): + idm_active = self.idm_active + + class _Resolver: + @staticmethod + def resolve(session, tenant_id): + del session, tenant_id + return type( + "State", + (), + {"effective_modules": ("idm",) if idm_active else ()}, + )() + + return _Resolver() + + def require_tenant_capability(self, name, session, **kwargs): + del session, kwargs + self._assert_capability(name) + return self.provider + + def manifests(self): + return (type("Manifest", (), {"id": "idm"})(),) + + @staticmethod + def _assert_capability(name: str) -> None: + if name != IDM_DSAR_CAPABILITY: + raise KeyError(name) + + +class IdmDsarProviderTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:", future=True) + Base.metadata.create_all(bind=self.engine) + self.session = sessionmaker(bind=self.engine, future=True)() + now = datetime.now(timezone.utc) + + self.identity = CanonicalIdentity( + id="identity-1", + display_name="Subject", + settings={"secret": "identity-settings-do-not-export"}, + ) + other_identity = CanonicalIdentity( + id="identity-other", + display_name="Unrelated Person", + settings={"secret": "other-identity-settings-do-not-export"}, + ) + unit = OrganizationUnit( + id="unit-1", + tenant_id="tenant-1", + slug="residents", + name="Residents Office", + ) + function = OrganizationFunction( + id="function-1", + tenant_id="tenant-1", + organization_unit_id=unit.id, + slug="case-worker", + name="Case worker", + ) + acting_function = OrganizationFunction( + id="function-2", + tenant_id="tenant-1", + organization_unit_id=unit.id, + slug="acting-case-worker", + name="Acting case worker", + ) + self.assignment = IdmOrganizationFunctionAssignment( + id="assignment-1", + tenant_id="tenant-1", + identity_id=self.identity.id, + account_id="account-1", + function_id=function.id, + organization_unit_id=unit.id, + source="direct", + valid_from=now, + settings={"secret": "assignment-settings-do-not-export"}, + ) + acting_assignment = IdmOrganizationFunctionAssignment( + id="assignment-acting", + tenant_id="tenant-1", + identity_id=other_identity.id, + account_id="account-other", + function_id=acting_function.id, + organization_unit_id=unit.id, + source="acting_for", + delegated_from_assignment_id=self.assignment.id, + acting_for_account_id="account-1", + valid_from=now, + settings={"secret": "acting-settings-do-not-export"}, + ) + self.unrelated_assignment = IdmOrganizationFunctionAssignment( + id="assignment-unrelated", + tenant_id="tenant-1", + identity_id=other_identity.id, + account_id="account-other", + function_id=function.id, + organization_unit_id=unit.id, + source="direct", + settings={"secret": "unrelated-assignment-do-not-export"}, + ) + tenant_two_assignment = IdmOrganizationFunctionAssignment( + id="assignment-tenant-2", + tenant_id="tenant-2", + identity_id=self.identity.id, + account_id="account-1", + function_id=function.id, + organization_unit_id=unit.id, + source="directory", + settings={"secret": "other-tenant-assignment-do-not-export"}, + ) + self.group = IdmTypedGroup( + id="group-1", + tenant_id="tenant-1", + key="residents", + name="Residents", + group_type="business_group", + source_provider="ldap", + source_resource_id="private-group-ref-do-not-export", + properties={"secret": "group-properties-do-not-export"}, + provenance={"secret": "group-provenance-do-not-export"}, + ) + self.relationship = IdmIdentityRelationship( + id="relationship-1", + tenant_id="tenant-1", + relationship_kind="member_of", + subject_identity_id=self.identity.id, + target_group_id=self.group.id, + role="member", + valid_from=now, + source_provider="ldap", + source_resource_id="private-relationship-ref-do-not-export", + source_revision="private-source-revision-do-not-export", + properties={"secret": "relationship-properties-do-not-export"}, + provenance={"secret": "relationship-provenance-do-not-export"}, + ) + related_relationship = IdmIdentityRelationship( + id="relationship-related", + tenant_id="tenant-1", + relationship_kind="representative_for", + subject_identity_id=other_identity.id, + related_identity_id=self.identity.id, + role="representative", + properties={"secret": "related-properties-do-not-export"}, + provenance={"secret": "related-provenance-do-not-export"}, + ) + unrelated_relationship = IdmIdentityRelationship( + id="relationship-unrelated", + tenant_id="tenant-1", + relationship_kind="member_of", + subject_identity_id=other_identity.id, + target_group_id=self.group.id, + role="member", + ) + tenant_two_relationship = IdmIdentityRelationship( + id="relationship-tenant-2", + tenant_id="tenant-2", + relationship_kind="member_of", + subject_identity_id=self.identity.id, + target_group_id=self.group.id, + role="member", + properties={"secret": "other-tenant-relationship-do-not-export"}, + ) + self.change = IdmFunctionAssignmentChange( + id="change-1", + tenant_id="tenant-1", + kind="request", + state="approved", + profile="self_request", + function_id=function.id, + organization_unit_id=unit.id, + candidate_identity_id=self.identity.id, + candidate_account_id="account-1", + initiator_account_id="account-other", + initiator_identity_id=other_identity.id, + justification="private-justification-do-not-export", + evidence=["private-evidence-do-not-export"], + requested_valid_from=now, + required_steps=["approval-secret-do-not-export"], + completed_steps=["approval-secret-do-not-export"], + policy_decision={"secret": "policy-decision-do-not-export"}, + workflow_definition_id="workflow-secret-do-not-export", + workflow_instance_id="workflow-instance-do-not-export", + idempotency_key="idempotency-key-do-not-export", + outcome_reason="outcome-reason-do-not-export", + metadata_={"secret": "change-metadata-do-not-export"}, + ) + initiated_change = IdmFunctionAssignmentChange( + id="change-initiated", + tenant_id="tenant-1", + kind="grant", + state="pending", + profile="authority_grant", + function_id=acting_function.id, + organization_unit_id=unit.id, + candidate_identity_id=other_identity.id, + candidate_account_id="account-other", + initiator_account_id="account-1", + initiator_identity_id=self.identity.id, + justification="third-party-justification-do-not-export", + evidence=["third-party-evidence-do-not-export"], + idempotency_key="initiated-change-key-do-not-export", + metadata_={"secret": "initiated-metadata-do-not-export"}, + ) + unrelated_change = IdmFunctionAssignmentChange( + id="change-unrelated", + tenant_id="tenant-1", + kind="grant", + state="pending", + profile="authority_grant", + function_id=function.id, + organization_unit_id=unit.id, + candidate_identity_id=other_identity.id, + candidate_account_id="account-other", + initiator_account_id="account-other", + initiator_identity_id=other_identity.id, + justification="unrelated-change-do-not-export", + idempotency_key="unrelated-change-key", + ) + tenant_two_change = IdmFunctionAssignmentChange( + id="change-tenant-2", + tenant_id="tenant-2", + kind="request", + state="pending", + profile="self_request", + function_id=function.id, + organization_unit_id=unit.id, + candidate_identity_id=self.identity.id, + candidate_account_id="account-1", + initiator_account_id="account-1", + initiator_identity_id=self.identity.id, + justification="other-tenant-change-do-not-export", + idempotency_key="tenant-two-key", + ) + self.event = IdmFunctionAssignmentChangeEvent( + id="event-1", + tenant_id="tenant-1", + change_id=self.change.id, + sequence=1, + action="approved", + from_state="pending", + to_state="approved", + actor_account_id="account-other", + actor_identity_id=other_identity.id, + actor_assignment_id=self.unrelated_assignment.id, + comment="private-event-comment-do-not-export", + evidence=["private-event-evidence-do-not-export"], + policy_decision={"secret": "event-policy-do-not-export"}, + workflow_step_id="workflow-step-do-not-export", + details={"secret": "event-details-do-not-export"}, + created_at=now, + ) + actor_event = IdmFunctionAssignmentChangeEvent( + id="event-actor", + tenant_id="tenant-1", + change_id=unrelated_change.id, + sequence=1, + action="reviewed", + from_state="pending", + to_state="pending", + actor_account_id="account-1", + actor_identity_id=self.identity.id, + actor_assignment_id=self.assignment.id, + comment="actor-comment-do-not-export", + evidence=["actor-evidence-do-not-export"], + policy_decision={"secret": "actor-policy-do-not-export"}, + workflow_step_id="actor-workflow-step-do-not-export", + details={"secret": "actor-details-do-not-export"}, + created_at=now, + ) + tenant_two_event = IdmFunctionAssignmentChangeEvent( + id="event-tenant-2", + tenant_id="tenant-2", + change_id=tenant_two_change.id, + sequence=1, + action="requested", + to_state="pending", + actor_account_id="account-1", + actor_identity_id=self.identity.id, + details={"secret": "other-tenant-event-do-not-export"}, + created_at=now, + ) + self.session.add_all( + [ + self.identity, + other_identity, + unit, + function, + acting_function, + self.assignment, + acting_assignment, + self.unrelated_assignment, + tenant_two_assignment, + self.group, + self.relationship, + related_relationship, + unrelated_relationship, + tenant_two_relationship, + self.change, + initiated_change, + unrelated_change, + tenant_two_change, + self.event, + actor_event, + tenant_two_event, + ] + ) + self.session.commit() + self.provider = IdmDsarProvider() + self.subject = DsarSubjectRef( + account_id="account-1", + identity_id=self.identity.id, + ) + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_manifest_publishes_protocol_conforming_provider(self) -> None: + self.assertIn( + IDM_DSAR_CAPABILITY, + {item.name for item in manifest.provides_interfaces}, + ) + provider = manifest.capability_factories[IDM_DSAR_CAPABILITY](None) + self.assertIsInstance(provider, DsarProvider) + + def test_search_is_tenant_scoped_third_party_safe_and_minimized(self) -> None: + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=self.subject, + ) + + self.assertTrue( + { + "idm_function_assignment", + "idm_identity_relationship", + "idm_typed_group_context", + "idm_function_assignment_change", + "idm_function_assignment_change_event", + }.issubset({record.resource_type for record in records}) + ) + serialized = repr([record.to_dict() for record in records]) + self.assertIn("assignment-acting", serialized) + self.assertIn("relationship-related", serialized) + self.assertIn("change-initiated", serialized) + self.assertIn("event-actor", serialized) + excluded = ( + "identity-other", + "account-other", + "assignment-settings-do-not-export", + "private-group-ref-do-not-export", + "group-properties-do-not-export", + "group-provenance-do-not-export", + "private-relationship-ref-do-not-export", + "private-source-revision-do-not-export", + "relationship-properties-do-not-export", + "relationship-provenance-do-not-export", + "private-justification-do-not-export", + "private-evidence-do-not-export", + "approval-secret-do-not-export", + "policy-decision-do-not-export", + "workflow-secret-do-not-export", + "workflow-instance-do-not-export", + "idempotency-key-do-not-export", + "outcome-reason-do-not-export", + "change-metadata-do-not-export", + "private-event-comment-do-not-export", + "private-event-evidence-do-not-export", + "event-policy-do-not-export", + "workflow-step-do-not-export", + "event-details-do-not-export", + "unrelated-change-do-not-export", + "other-tenant-assignment-do-not-export", + "other-tenant-relationship-do-not-export", + "other-tenant-change-do-not-export", + "other-tenant-event-do-not-export", + ) + for value in excluded: + self.assertNotIn(value, serialized) + + def test_conflicting_and_uncorroborated_direct_selectors_fail_closed(self) -> None: + conflict = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + identity_id=self.identity.id, + external_references={"idm.identity": "identity-other"}, + ), + ) + direct_conflict = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + identity_id=self.identity.id, + external_references={ + "idm.assignment": self.unrelated_assignment.id, + }, + ), + ) + self.assertEqual((), conflict) + self.assertEqual((), direct_conflict) + + def test_plan_retains_evidence_and_routes_facts_to_manual_review(self) -> None: + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=self.subject, + ) + actions = self.provider.plan_erasure( + self.session, + tenant_id="tenant-1", + subject=self.subject, + records=records, + ) + + self.assertEqual( + {"manual_review", "retain"}, + {action.kind for action in actions}, + ) + self.assertFalse(any(action.executable for action in actions)) + results = self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=self.subject, + actions=actions, + request_id="dsar-idm-1", + ) + self.assertEqual({"blocked"}, {result.status for result in results}) + self.assertIsNotNone( + self.session.get(IdmOrganizationFunctionAssignment, self.assignment.id) + ) + + def test_execution_rejects_foreign_and_forged_executable_actions(self) -> None: + actions = ( + DsarErasureActionRef( + action_id="identity:delete:assignment:assignment-1", + provider_id="identity", + module_id="identity", + kind="delete", + resource_type="idm_function_assignment", + resource_id=self.assignment.id, + title="Foreign action", + rationale="Must be rejected", + executable=True, + ), + DsarErasureActionRef( + action_id="idm:delete:assignment:assignment-1", + provider_id="idm", + module_id="idm", + kind="delete", + resource_type="idm_function_assignment", + resource_id=self.assignment.id, + title="Forged action", + rationale="Must be rejected", + executable=True, + ), + ) + for action in actions: + with self.assertRaises(ValueError): + self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=self.subject, + actions=(action,), + request_id="dsar-idm-2", + ) + + def test_core_workflow_discovers_active_and_inactive_provider(self) -> None: + request = create_data_subject_request( + self.session, + tenant_id="tenant-1", + reference="DSAR-IDM-1", + request_kind="access", + subject=self.subject, + purpose="Respond to an authorized privacy request.", + legal_basis="Article 15 GDPR", + due_at=None, + requested_by_account_id="privacy-officer", + ) + self.session.commit() + search_data_subject_request( + self.session, + registry=_Registry(self.provider), + row=request, + expected_revision=1, + ) + self.assertEqual(["idm"], request.coverage["covered_modules"]) + + disabled = create_data_subject_request( + self.session, + tenant_id="tenant-1", + reference="DSAR-IDM-DISABLED", + request_kind="access", + subject=self.subject, + purpose="Verify disabled-module coverage.", + legal_basis="Article 15 GDPR", + due_at=None, + requested_by_account_id="privacy-officer", + ) + search_data_subject_request( + self.session, + registry=_Registry(self.provider, idm_active=False), + row=disabled, + expected_revision=1, + ) + self.assertEqual(0, disabled.search_result["record_count"]) + self.assertEqual( + [IDM_DSAR_CAPABILITY], + disabled.coverage["inactive_provider_capabilities"], + ) + + +if __name__ == "__main__": + unittest.main()