From bfc38a7bc9ca3bf6c83084bcfa0f12ced769fddc Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 24 Aug 2026 16:07:38 +0200 Subject: [PATCH] feat: cover tenant erasure evidence in DSAR --- docs/TENANCY_MODULE_BOUNDARY.md | 8 + src/govoplan_tenancy/backend/dsar_provider.py | 218 ++++++++++++++++++ src/govoplan_tenancy/backend/manifest.py | 89 ++++++- tests/test_dsar_provider.py | 96 ++++++++ 4 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 src/govoplan_tenancy/backend/dsar_provider.py create mode 100644 tests/test_dsar_provider.py diff --git a/docs/TENANCY_MODULE_BOUNDARY.md b/docs/TENANCY_MODULE_BOUNDARY.md index fbc1cd0..ec8e1f7 100644 --- a/docs/TENANCY_MODULE_BOUNDARY.md +++ b/docs/TENANCY_MODULE_BOUNDARY.md @@ -46,6 +46,14 @@ only after providers finish, a fresh inventory is clear, and delete vetoes and tenant counts are zero. The durable operation then removes its free-text reason and never stores typed confirmation, secrets, or erased tenant content. +The module's `privacy.dsar.tenancy` provider returns bounded requester and +approver roles, approval timestamps, operation state, and an unfinished +request reason only to the corroborated account selector. These actor and +checkpoint references are immutable authorization, separation-of-duties, and +recovery evidence, so the provider returns an explicit non-executable retain +action. Typed confirmation and credentials are never persisted; free-text +reason is removed when the tenant-erasure operation completes. + Tenant lifecycle planning uses registered tenant summary providers and delete veto providers. Modules that own tenant-scoped data must contribute summaries so destructive deletion cannot silently miss their rows. diff --git a/src/govoplan_tenancy/backend/dsar_provider.py b/src/govoplan_tenancy/backend/dsar_provider.py new file mode 100644 index 0000000..07c9af0 --- /dev/null +++ b/src/govoplan_tenancy/backend/dsar_provider.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from collections.abc import Sequence +from datetime import UTC, datetime + +from sqlalchemy.orm import Session + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarExecutionResultRef, + DsarRecordRef, + DsarSubjectRef, + dsar_capability_name, +) +from govoplan_tenancy.backend.db.models import TenantErasureOperation + + +TENANCY_DSAR_CAPABILITY = dsar_capability_name("tenancy") +_MAX_TENANT_OPERATIONS = 5_000 +_MAX_SUBJECT_RECORDS = 500 + + +class TenancyDsarProvider: + provider_id = "tenancy" + module_id = "tenancy" + + def search_subject( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + ) -> Sequence[DsarRecordRef]: + db = _session(session) + account_id = _account_id(subject) + if account_id is None: + return () + operations = ( + db.query(TenantErasureOperation) + .filter(TenantErasureOperation.tenant_id == tenant_id) + .order_by(TenantErasureOperation.created_at, TenantErasureOperation.id) + .limit(_MAX_TENANT_OPERATIONS + 1) + .all() + ) + if len(operations) > _MAX_TENANT_OPERATIONS: + raise ValueError( + "Tenancy DSAR operation scan limit exceeded; narrow the tenant scope." + ) + records = tuple( + record + for operation in operations + if (record := _subject_record(operation, account_id)) is not None + ) + if len(records) > _MAX_SUBJECT_RECORDS: + raise ValueError( + "Tenancy DSAR subject result limit exceeded; use a narrower request window." + ) + return records + + def plan_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + records: Sequence[DsarRecordRef], + ) -> Sequence[DsarErasureActionRef]: + del session, tenant_id + if _account_id(subject) is None: + raise ValueError("Tenancy DSAR requires one corroborated account.") + actions: list[DsarErasureActionRef] = [] + for record in records: + _validate_record(record) + actions.append( + DsarErasureActionRef( + action_id=( + f"tenancy:retain:tenant_erasure_operation:{record.resource_id}" + ), + provider_id=self.provider_id, + module_id=self.module_id, + kind="retain", + resource_type=record.resource_type, + resource_id=record.resource_id, + title="Retain tenant-erasure governance evidence", + rationale=( + "The actor reference and approval timestamp are bounded " + "security evidence required to prove authorization, separation " + "of duties, checkpoints, and recovery. The operation never stores " + "typed confirmation or credentials, and its free-text reason is " + "removed at completion." + ), + executable=False, + irreversible=False, + ) + ) + return tuple(actions) + + def execute_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + actions: Sequence[DsarErasureActionRef], + request_id: str, + ) -> Sequence[DsarExecutionResultRef]: + del session, tenant_id, request_id + if _account_id(subject) is None: + raise ValueError("Tenancy DSAR requires one corroborated account.") + results: list[DsarExecutionResultRef] = [] + for action in actions: + _validate_action(action) + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="blocked", + summary=( + "Tenant-erasure authorization and recovery evidence is retained " + "under the recorded governance purpose." + ), + ) + ) + return tuple(results) + + +def _subject_record( + operation: TenantErasureOperation, + account_id: str, +) -> DsarRecordRef | None: + requested = operation.requested_by_account_id == account_id + approvals = tuple( + item + for item in operation.approvals or [] + if item.get("account_id") == account_id + ) + if not requested and not approvals: + return None + data: dict[str, object] = { + "actor_roles": [ + *(("requester",) if requested else ()), + *(("approver",) if approvals else ()), + ], + "approval_timestamps": [ + str(item.get("approved_at")) + for item in approvals + if item.get("approved_at") + ], + "state": operation.state, + "destructive_started": operation.destructive_started, + "completed_at": _iso(operation.completed_at), + } + if requested and operation.reason: + data["request_reason"] = operation.reason + return DsarRecordRef( + provider_id="tenancy", + module_id="tenancy", + resource_type="tenant_erasure_operation", + resource_id=operation.id, + category="security_and_governance_evidence", + title="Tenant-erasure authorization and recovery evidence", + data=data, + observed_at=_aware(operation.updated_at), + immutable_evidence=True, + retention_reason=( + "Authorization, separation-of-duties, destructive-effect, and recovery evidence." + ), + ) + + +def _account_id(subject: DsarSubjectRef) -> str | None: + candidates = { + value.strip() + for value in ( + subject.account_id, + subject.external_references.get("access.account"), + subject.external_references.get("tenancy.actor_account"), + ) + if isinstance(value, str) and value.strip() + } + return next(iter(candidates)) if len(candidates) == 1 else None + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Tenancy DSAR requires a SQLAlchemy Session.") + return value + + +def _aware(value: datetime | None) -> datetime | None: + if value is None or value.tzinfo is not None: + return value + return value.replace(tzinfo=UTC) + + +def _iso(value: datetime | None) -> str | None: + aware = _aware(value) + return aware.isoformat() if aware else None + + +def _validate_record(record: DsarRecordRef) -> None: + if record.provider_id != "tenancy" or record.module_id != "tenancy": + raise ValueError("Tenancy DSAR cannot plan a foreign provider record.") + if record.resource_type != "tenant_erasure_operation" or not record.resource_id: + raise ValueError("Tenancy DSAR record identity is invalid.") + + +def _validate_action(action: DsarErasureActionRef) -> None: + if action.provider_id != "tenancy" or action.module_id != "tenancy": + raise ValueError("Tenancy DSAR cannot execute a foreign provider action.") + if ( + action.kind != "retain" + or action.executable + or not action.action_id.startswith("tenancy:retain:") + ): + raise ValueError("Tenancy DSAR action is invalid.") + + +__all__ = ["TENANCY_DSAR_CAPABILITY", "TenancyDsarProvider"] diff --git a/src/govoplan_tenancy/backend/manifest.py b/src/govoplan_tenancy/backend/manifest.py index b26e535..4bba5b1 100644 --- a/src/govoplan_tenancy/backend/manifest.py +++ b/src/govoplan_tenancy/backend/manifest.py @@ -3,7 +3,9 @@ from __future__ import annotations from pathlib import Path from govoplan_core.core.modules import with_documentation_structured_translations -from govoplan_tenancy.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS +from govoplan_tenancy.backend.german_structured_documentation import ( + GERMAN_STRUCTURED_TRANSLATIONS, +) from govoplan_core.core.access import ( CAPABILITY_AUTH_PERMISSION_EVALUATOR, @@ -12,12 +14,14 @@ from govoplan_core.core.access import ( CAPABILITY_TENANCY_TENANT_RESOLVER, ) from govoplan_core.core.modules import ( + CapabilityDocumentation, DocumentationCondition, DocumentationLink, DocumentationTopic, FrontendModule, MigrationSpec, ModuleContext, + ModuleInterfaceProvider, ModuleManifest, ) from govoplan_core.core.module_guards import ( @@ -28,6 +32,10 @@ from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base from govoplan_tenancy.backend.db.models import TenantErasureOperation +from govoplan_tenancy.backend.dsar_provider import ( + TENANCY_DSAR_CAPABILITY, + TenancyDsarProvider, +) def _tenant_resolver(context: ModuleContext): @@ -48,6 +56,10 @@ def _route_factory(context: ModuleContext): return aggregate +def _dsar_provider(_context: ModuleContext) -> TenancyDsarProvider: + return TenancyDsarProvider() + + manifest = ModuleManifest( id="tenancy", name="Tenancy", @@ -57,9 +69,23 @@ manifest = ModuleManifest( CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, ), + provides_interfaces=( + ModuleInterfaceProvider(name=TENANCY_DSAR_CAPABILITY, version="0.1.0"), + ), route_factory=_route_factory, capability_factories={ CAPABILITY_TENANCY_TENANT_RESOLVER: _tenant_resolver, + TENANCY_DSAR_CAPABILITY: _dsar_provider, + }, + capability_documentation={ + TENANCY_DSAR_CAPABILITY: CapabilityDocumentation( + label="Tenancy data-subject request provider", + summary=( + "Exports bounded tenant-erasure actor and approval evidence and " + "explains its non-executable governance retention." + ), + contract_version="0.1.0", + ), }, migration_spec=MigrationSpec( module_id="tenancy", @@ -216,7 +242,9 @@ manifest = ModuleManifest( "key. Cancellation is rejected after destructive work starts. Completion removes " "the Core scope only after a new inventory and all delete vetoes are clear, then " "retains bounded operation and audit evidence without the typed confirmation, " - "request reason, credentials, or erased tenant content." + "request reason, credentials, or erased tenant content. The Tenancy DSAR " + "provider exports a subject's requester/approver role and timestamps and " + "explains why this bounded authorization and recovery evidence is retained." ), documentation_types=("admin",), audience=("system_admin", "operator", "security_reviewer"), @@ -259,7 +287,8 @@ manifest = ModuleManifest( "reconciliation_required; der Abgleich nutzt denselben Anbieter-Idempotenzschlüssel. Nach Beginn destruktiver Arbeit ist ein " "Abbruch ausgeschlossen. Core entfernt den Mandantenbereich erst, wenn eine neue Inventur und alle Löschvetos frei sind. " "Danach bleiben begrenzte Vorgangs- und Auditnachweise ohne Texteingabebestätigung, Antragsgrund, Anmeldedaten oder gelöschte " - "Mandanteninhalte erhalten." + "Mandanteninhalte erhalten. Der Tenancy-DSAR-Anbieter exportiert Rolle und Zeitstempel der betroffenen antragstellenden oder " + "freigebenden Person und erläutert, warum dieser begrenzte Autorisierungs- und Wiederherstellungsnachweis aufbewahrt wird." ), } }, @@ -268,6 +297,60 @@ manifest = ModuleManifest( "help_contexts": ["tenancy.admin.tenant-erasure"], }, ), + DocumentationTopic( + id="tenancy.workflow.data-subject-request", + title="Review Tenancy evidence in a data-subject request", + summary=( + "Privacy officers can export a subject's bounded role in tenant-erasure " + "operations while preserving required governance evidence." + ), + body=( + "The Tenancy DSAR provider matches the authenticated account identifier " + "and returns a bounded set of matching tenant-erasure operations, failing " + "closed when the tenant scan or subject result limit is exceeded. " + "It reports only the operation state, the subject's requester or approver " + "role and timestamps, and whether destructive work or completion occurred. " + "An unfinished request reason is visible only to its requester; typed " + "confirmation, credentials, provider payloads, erased tenant content, and a " + "completed request reason are never exported. This authorization and recovery " + "record is immutable governance evidence, so the erasure plan marks it for " + "retention and execution returns a blocking explanation instead of deleting it. " + "Use Audit and the owning domain providers to complete the wider request." + ), + documentation_types=("admin",), + audience=("privacy_officer", "system_admin", "security_reviewer"), + related_modules=("access", "audit"), + translations={ + "de": { + "title": "Tenancy-Nachweise in einer Betroffenenanfrage prüfen", + "summary": ( + "Datenschutzverantwortliche können die begrenzte Rolle einer betroffenen " + "Person in Mandantenlöschvorgängen exportieren und erforderliche " + "Governance-Nachweise bewahren." + ), + "body": ( + "Der Tenancy-DSAR-Anbieter gleicht die authentifizierte Konto-ID ab und " + "liefert eine begrenzte Menge passender Mandantenlöschvorgänge; beim " + "Überschreiten der Mandanten- oder Betroffenenbegrenzung bricht er sicher ab. " + "Ausgegeben werden nur Vorgangszustand, Rolle und Zeitstempel der " + "betroffenen antragstellenden oder freigebenden Person sowie Angaben " + "dazu, ob destruktive Arbeit oder der Abschluss erfolgt ist. Ein noch " + "offener Antragsgrund ist ausschließlich für die antragstellende Person " + "sichtbar; Texteingabebestätigung, Anmeldedaten, Anbieterinhalte, gelöschte " + "Mandantendaten und der Grund eines abgeschlossenen Antrags werden nie " + "exportiert. Dieser Autorisierungs- und Wiederherstellungsnachweis ist ein " + "unveränderlicher Governance-Beleg. Der Löschplan kennzeichnet ihn daher " + "zur Aufbewahrung und die Ausführung liefert statt einer Löschung eine " + "blockierende Begründung. Audit und die zuständigen Fachmodule vervollständigen " + "die übergreifende Anfrage." + ), + } + }, + metadata={ + "kind": "workflow", + "help_contexts": ["tenancy.admin.data-subject-request"], + }, + ), DocumentationTopic( id="tenancy.reference.admin-fields", title="Tenant administration fields and consequences", diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py new file mode 100644 index 0000000..c46236a --- /dev/null +++ b/tests/test_dsar_provider.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.dsar import DsarSubjectRef +from govoplan_core.db.base import Base +from govoplan_tenancy.backend.db.models import TenantErasureOperation +from govoplan_tenancy.backend.dsar_provider import TenancyDsarProvider + + +def _operation(now: datetime) -> TenantErasureOperation: + return TenantErasureOperation( + tenant_id="tenant-1", + state="ready", + idempotency_key="request-1234", + request_digest="a" * 64, + preview_digest="b" * 64, + preview={"schema_version": 1}, + previewed_at=now, + preview_expires_at=now + timedelta(minutes=15), + policy={"required_approvals": 2}, + approvals=[ + {"account_id": "account-1", "approved_at": now.isoformat()}, + {"account_id": "account-2", "approved_at": now.isoformat()}, + ], + steps=[], + requested_by_account_id="account-1", + reason="Contract ended", + destructive_started=False, + revision=3, + ) + + +def test_dsar_exports_only_subject_actor_evidence_and_retains_it() -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC) + with Session(engine) as session: + operation = _operation(now) + session.add(operation) + session.commit() + provider = TenancyDsarProvider() + + requester_records = provider.search_subject( + session, + tenant_id="tenant-1", + subject=DsarSubjectRef(account_id="account-1"), + ) + approver_records = provider.search_subject( + session, + tenant_id="tenant-1", + subject=DsarSubjectRef(account_id="account-2"), + ) + + assert requester_records[0].data["actor_roles"] == ["requester", "approver"] + assert requester_records[0].data["request_reason"] == "Contract ended" + assert approver_records[0].data["actor_roles"] == ["approver"] + assert "request_reason" not in approver_records[0].data + assert requester_records[0].immutable_evidence + + actions = provider.plan_erasure( + session, + tenant_id="tenant-1", + subject=DsarSubjectRef(account_id="account-1"), + records=requester_records, + ) + assert actions[0].kind == "retain" + assert not actions[0].executable + results = provider.execute_erasure( + session, + tenant_id="tenant-1", + subject=DsarSubjectRef(account_id="account-1"), + actions=actions, + request_id="dsar-1", + ) + assert results[0].status == "blocked" + + +def test_dsar_rejects_conflicting_account_selectors() -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as session: + assert ( + TenancyDsarProvider().search_subject( + session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + account_id="account-1", + external_references={"access.account": "account-2"}, + ), + ) + == () + )