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
+8
View File
@@ -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 tenant counts are zero. The durable operation then removes its free-text reason
and never stores typed confirmation, secrets, or erased tenant content. 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 Tenant lifecycle planning uses registered tenant summary providers and delete
veto providers. Modules that own tenant-scoped data must contribute summaries veto providers. Modules that own tenant-scoped data must contribute summaries
so destructive deletion cannot silently miss their rows. so destructive deletion cannot silently miss their rows.
@@ -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 pathlib import Path
from govoplan_core.core.modules import with_documentation_structured_translations 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 ( from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -12,12 +14,14 @@ from govoplan_core.core.access import (
CAPABILITY_TENANCY_TENANT_RESOLVER, CAPABILITY_TENANCY_TENANT_RESOLVER,
) )
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition, DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
MigrationSpec, MigrationSpec,
ModuleContext, ModuleContext,
ModuleInterfaceProvider,
ModuleManifest, ModuleManifest,
) )
from govoplan_core.core.module_guards import ( 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.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_tenancy.backend.db.models import TenantErasureOperation from govoplan_tenancy.backend.db.models import TenantErasureOperation
from govoplan_tenancy.backend.dsar_provider import (
TENANCY_DSAR_CAPABILITY,
TenancyDsarProvider,
)
def _tenant_resolver(context: ModuleContext): def _tenant_resolver(context: ModuleContext):
@@ -48,6 +56,10 @@ def _route_factory(context: ModuleContext):
return aggregate return aggregate
def _dsar_provider(_context: ModuleContext) -> TenancyDsarProvider:
return TenancyDsarProvider()
manifest = ModuleManifest( manifest = ModuleManifest(
id="tenancy", id="tenancy",
name="Tenancy", name="Tenancy",
@@ -57,9 +69,23 @@ manifest = ModuleManifest(
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
), ),
provides_interfaces=(
ModuleInterfaceProvider(name=TENANCY_DSAR_CAPABILITY, version="0.1.0"),
),
route_factory=_route_factory, route_factory=_route_factory,
capability_factories={ capability_factories={
CAPABILITY_TENANCY_TENANT_RESOLVER: _tenant_resolver, 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( migration_spec=MigrationSpec(
module_id="tenancy", module_id="tenancy",
@@ -216,7 +242,9 @@ manifest = ModuleManifest(
"key. Cancellation is rejected after destructive work starts. Completion removes " "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 " "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, " "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",), documentation_types=("admin",),
audience=("system_admin", "operator", "security_reviewer"), 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 " "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. " "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 " "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"], "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( DocumentationTopic(
id="tenancy.reference.admin-fields", id="tenancy.reference.admin-fields",
title="Tenant administration fields and consequences", title="Tenant administration fields and consequences",
+96
View File
@@ -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"},
),
)
== ()
)