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
|
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"]
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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