feat: cover tenant erasure evidence in DSAR
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-24 16:07:38 +02:00
parent e412c7d1bd
commit bfc38a7bc9
4 changed files with 408 additions and 3 deletions
@@ -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"]
+86 -3
View File
@@ -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",