feat: cover tenant erasure evidence in DSAR
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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"},
|
||||
),
|
||||
)
|
||||
== ()
|
||||
)
|
||||
Reference in New Issue
Block a user