feat: define governed tenant erasure contracts
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -142,6 +142,7 @@ system:tenants:read
|
|||||||
system:tenants:create
|
system:tenants:create
|
||||||
system:tenants:update
|
system:tenants:update
|
||||||
system:tenants:suspend
|
system:tenants:suspend
|
||||||
|
system:tenants:erase
|
||||||
|
|
||||||
system:accounts:read
|
system:accounts:read
|
||||||
system:accounts:create
|
system:accounts:create
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ The following contracts are the baseline API that modules can rely on:
|
|||||||
- bounded reference-option search provider contract
|
- bounded reference-option search provider contract
|
||||||
- single-tenant and optional batched tenant summary provider contracts
|
- single-tenant and optional batched tenant summary provider contracts
|
||||||
- tenant delete-veto provider contract
|
- tenant delete-veto provider contract
|
||||||
|
- provider-neutral tenant-erasure preview, step, idempotency, and
|
||||||
|
reconciliation contracts in `govoplan_core.core.tenant_erasure`
|
||||||
- WebUI module contribution contract
|
- WebUI module contribution contract
|
||||||
- navigation metadata contract
|
- navigation metadata contract
|
||||||
- command/event envelope contract
|
- command/event envelope contract
|
||||||
@@ -149,6 +151,17 @@ Destructive tenant lifecycle planning deliberately continues to use the
|
|||||||
single-tenant path so it invokes every registered provider for the target
|
single-tenant path so it invokes every registered provider for the target
|
||||||
tenant, independent of ordinary list-page projections.
|
tenant, independent of ordinary list-page projections.
|
||||||
|
|
||||||
|
Governed populated-tenant erasure is separate from ordinary delete vetoes.
|
||||||
|
Modules contribute `tenancy.erasure_provider.<module_id>` capabilities with a
|
||||||
|
bounded resource inventory, explicit erase/retain/legal-hold/external/key/
|
||||||
|
backup dispositions, ordered destructive warnings, idempotent step execution,
|
||||||
|
and reconciliation. The collector fails closed when a provider is invalid or
|
||||||
|
fails. A module with nonzero tenant summary counts and no erasure capability is
|
||||||
|
reported as unsupported and blocks execution; modules with neither contract
|
||||||
|
are explicitly projected as outside tenant-persistence scope. Provider
|
||||||
|
evidence contains counts and stable references only and must never contain
|
||||||
|
secrets or erased subject data.
|
||||||
|
|
||||||
This list is the Milestone A kernel-contract freeze baseline. New module work
|
This list is the Milestone A kernel-contract freeze baseline. New module work
|
||||||
may extend the kernel by adding explicit contracts, but existing contracts must
|
may extend the kernel by adding explicit contracts, but existing contracts must
|
||||||
remain source-compatible through the 0.1.x split line unless a migration shim
|
remain source-compatible through the 0.1.x split line unless a migration shim
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.42"
|
version = "0.1.43"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX = "tenancy.erasure_provider."
|
||||||
|
|
||||||
|
TenantErasureDisposition = Literal[
|
||||||
|
"erase",
|
||||||
|
"retain",
|
||||||
|
"legal_hold",
|
||||||
|
"external_cleanup",
|
||||||
|
"key_destroy",
|
||||||
|
"backup_expiry",
|
||||||
|
"unavailable",
|
||||||
|
]
|
||||||
|
TenantErasureStepKind = Literal[
|
||||||
|
"export",
|
||||||
|
"erase",
|
||||||
|
"retain",
|
||||||
|
"external_cleanup",
|
||||||
|
"key_destroy",
|
||||||
|
"backup_expiry",
|
||||||
|
"verify",
|
||||||
|
]
|
||||||
|
TenantErasureResultState = Literal[
|
||||||
|
"completed",
|
||||||
|
"pending",
|
||||||
|
"blocked",
|
||||||
|
"outcome_unknown",
|
||||||
|
]
|
||||||
|
|
||||||
|
_DISPOSITIONS = frozenset(
|
||||||
|
{
|
||||||
|
"erase",
|
||||||
|
"retain",
|
||||||
|
"legal_hold",
|
||||||
|
"external_cleanup",
|
||||||
|
"key_destroy",
|
||||||
|
"backup_expiry",
|
||||||
|
"unavailable",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_STEP_KINDS = frozenset(
|
||||||
|
{
|
||||||
|
"export",
|
||||||
|
"erase",
|
||||||
|
"retain",
|
||||||
|
"external_cleanup",
|
||||||
|
"key_destroy",
|
||||||
|
"backup_expiry",
|
||||||
|
"verify",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_RESULT_STATES = frozenset(
|
||||||
|
{"completed", "pending", "blocked", "outcome_unknown"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: str, label: str, *, maximum: int) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if (
|
||||||
|
not normalized
|
||||||
|
or len(normalized) > maximum
|
||||||
|
or any(ord(character) < 32 for character in normalized)
|
||||||
|
):
|
||||||
|
raise ValueError(f"Tenant erasure {label} is invalid.")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _texts(
|
||||||
|
values: tuple[str, ...],
|
||||||
|
label: str,
|
||||||
|
*,
|
||||||
|
maximum_items: int = 100,
|
||||||
|
maximum_length: int = 500,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if len(values) > maximum_items:
|
||||||
|
raise ValueError(f"Tenant erasure {label} has too many entries.")
|
||||||
|
normalized = tuple(
|
||||||
|
_text(value, label, maximum=maximum_length) for value in values
|
||||||
|
)
|
||||||
|
if len(normalized) != len(set(normalized)):
|
||||||
|
raise ValueError(f"Tenant erasure {label} contains duplicates.")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics(values: Mapping[str, int]) -> dict[str, int]:
|
||||||
|
if len(values) > 30:
|
||||||
|
raise ValueError("Tenant erasure metrics has too many entries.")
|
||||||
|
normalized: dict[str, int] = {}
|
||||||
|
for key, value in values.items():
|
||||||
|
normalized_key = _text(key, "metric key", maximum=80)
|
||||||
|
if type(value) is not int or value < 0:
|
||||||
|
raise ValueError("Tenant erasure metric values must be non-negative integers.")
|
||||||
|
normalized[normalized_key] = value
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TenantErasureResource:
|
||||||
|
resource_type: str
|
||||||
|
count: int
|
||||||
|
disposition: TenantErasureDisposition
|
||||||
|
summary: str
|
||||||
|
governance_ref: str | None = None
|
||||||
|
external: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_text(self.resource_type, "resource type", maximum=120)
|
||||||
|
_text(self.summary, "resource summary", maximum=1000)
|
||||||
|
if type(self.count) is not int or self.count < 0:
|
||||||
|
raise ValueError("Tenant erasure resource count is invalid.")
|
||||||
|
if self.disposition not in _DISPOSITIONS:
|
||||||
|
raise ValueError("Tenant erasure resource disposition is invalid.")
|
||||||
|
if self.governance_ref is not None:
|
||||||
|
_text(self.governance_ref, "governance reference", maximum=300)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"resource_type": self.resource_type,
|
||||||
|
"count": self.count,
|
||||||
|
"disposition": self.disposition,
|
||||||
|
"summary": self.summary,
|
||||||
|
"governance_ref": self.governance_ref,
|
||||||
|
"external": self.external,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TenantErasureStep:
|
||||||
|
step_id: str
|
||||||
|
kind: TenantErasureStepKind
|
||||||
|
summary: str
|
||||||
|
destructive: bool
|
||||||
|
irreversible: bool
|
||||||
|
requires_reconciliation: bool = False
|
||||||
|
depends_on: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_text(self.step_id, "step id", maximum=160)
|
||||||
|
_text(self.summary, "step summary", maximum=1000)
|
||||||
|
if self.kind not in _STEP_KINDS:
|
||||||
|
raise ValueError("Tenant erasure step kind is invalid.")
|
||||||
|
_texts(self.depends_on, "step dependencies", maximum_length=160)
|
||||||
|
if self.step_id in self.depends_on:
|
||||||
|
raise ValueError("Tenant erasure step cannot depend on itself.")
|
||||||
|
if self.irreversible and not self.destructive:
|
||||||
|
raise ValueError("An irreversible tenant erasure step must be destructive.")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"step_id": self.step_id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"summary": self.summary,
|
||||||
|
"destructive": self.destructive,
|
||||||
|
"irreversible": self.irreversible,
|
||||||
|
"requires_reconciliation": self.requires_reconciliation,
|
||||||
|
"depends_on": list(self.depends_on),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TenantErasurePreview:
|
||||||
|
module_id: str
|
||||||
|
complete: bool
|
||||||
|
resources: tuple[TenantErasureResource, ...] = ()
|
||||||
|
steps: tuple[TenantErasureStep, ...] = ()
|
||||||
|
blockers: tuple[str, ...] = ()
|
||||||
|
warnings: tuple[str, ...] = ()
|
||||||
|
provider_revision: str = "1"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_text(self.module_id, "module id", maximum=120)
|
||||||
|
_text(self.provider_revision, "provider revision", maximum=120)
|
||||||
|
_texts(self.blockers, "blockers", maximum_length=1000)
|
||||||
|
_texts(self.warnings, "warnings", maximum_length=1000)
|
||||||
|
if len(self.resources) > 500 or len(self.steps) > 500:
|
||||||
|
raise ValueError("Tenant erasure preview is too large.")
|
||||||
|
resource_types = [item.resource_type for item in self.resources]
|
||||||
|
if len(resource_types) != len(set(resource_types)):
|
||||||
|
raise ValueError("Tenant erasure preview repeats a resource type.")
|
||||||
|
resources_requiring_action = tuple(
|
||||||
|
item for item in self.resources if item.count > 0
|
||||||
|
)
|
||||||
|
if resources_requiring_action and not self.steps and not self.blockers:
|
||||||
|
raise ValueError(
|
||||||
|
"Tenant erasure resources require steps or an explicit blocker."
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
item.count > 0 and item.disposition == "unavailable"
|
||||||
|
for item in self.resources
|
||||||
|
) and not self.blockers:
|
||||||
|
raise ValueError(
|
||||||
|
"Unavailable tenant erasure resources require an explicit blocker."
|
||||||
|
)
|
||||||
|
if not self.complete and not self.blockers:
|
||||||
|
raise ValueError(
|
||||||
|
"An incomplete tenant erasure preview requires an explicit blocker."
|
||||||
|
)
|
||||||
|
step_ids = [item.step_id for item in self.steps]
|
||||||
|
if len(step_ids) != len(set(step_ids)):
|
||||||
|
raise ValueError("Tenant erasure preview repeats a step id.")
|
||||||
|
known_step_ids = set(step_ids)
|
||||||
|
if any(
|
||||||
|
dependency not in known_step_ids
|
||||||
|
for step in self.steps
|
||||||
|
for dependency in step.depends_on
|
||||||
|
):
|
||||||
|
raise ValueError("Tenant erasure step references an unknown dependency.")
|
||||||
|
remaining = {
|
||||||
|
step.step_id: set(step.depends_on)
|
||||||
|
for step in self.steps
|
||||||
|
}
|
||||||
|
resolved: set[str] = set()
|
||||||
|
while remaining:
|
||||||
|
ready = sorted(
|
||||||
|
step_id
|
||||||
|
for step_id, dependencies in remaining.items()
|
||||||
|
if dependencies.issubset(resolved)
|
||||||
|
)
|
||||||
|
if not ready:
|
||||||
|
raise ValueError("Tenant erasure step dependencies contain a cycle.")
|
||||||
|
resolved.update(ready)
|
||||||
|
for step_id in ready:
|
||||||
|
remaining.pop(step_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allowed(self) -> bool:
|
||||||
|
return self.complete and not self.blockers
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"module_id": self.module_id,
|
||||||
|
"complete": self.complete,
|
||||||
|
"allowed": self.allowed,
|
||||||
|
"provider_revision": self.provider_revision,
|
||||||
|
"resources": [item.to_dict() for item in self.resources],
|
||||||
|
"steps": [item.to_dict() for item in self.steps],
|
||||||
|
"blockers": list(self.blockers),
|
||||||
|
"warnings": list(self.warnings),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TenantErasureStepResult:
|
||||||
|
state: TenantErasureResultState
|
||||||
|
summary: str
|
||||||
|
receipt_ref: str | None = None
|
||||||
|
metrics: Mapping[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.state not in _RESULT_STATES:
|
||||||
|
raise ValueError("Tenant erasure result state is invalid.")
|
||||||
|
_text(self.summary, "result summary", maximum=1000)
|
||||||
|
if self.receipt_ref is not None:
|
||||||
|
_text(self.receipt_ref, "receipt reference", maximum=500)
|
||||||
|
_metrics(self.metrics)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"state": self.state,
|
||||||
|
"summary": self.summary,
|
||||||
|
"receipt_ref": self.receipt_ref,
|
||||||
|
"metrics": dict(sorted(_metrics(self.metrics).items())),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TenantErasureProvider(Protocol):
|
||||||
|
module_id: str
|
||||||
|
|
||||||
|
def preview_tenant_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> TenantErasurePreview:
|
||||||
|
...
|
||||||
|
|
||||||
|
def execute_tenant_erasure_step(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
tenant_id: str,
|
||||||
|
step_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> TenantErasureStepResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
def reconcile_tenant_erasure_step(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
tenant_id: str,
|
||||||
|
step_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> TenantErasureStepResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TenantErasureInventory:
|
||||||
|
tenant_id: str
|
||||||
|
generated_at: datetime
|
||||||
|
complete: bool
|
||||||
|
modules: tuple[TenantErasurePreview, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allowed(self) -> bool:
|
||||||
|
return self.complete and all(item.allowed for item in self.modules)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
generated_at = self.generated_at
|
||||||
|
if generated_at.tzinfo is None:
|
||||||
|
generated_at = generated_at.replace(tzinfo=UTC)
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"generated_at": generated_at.astimezone(UTC).isoformat(),
|
||||||
|
"complete": self.complete,
|
||||||
|
"allowed": self.allowed,
|
||||||
|
"modules": [item.to_dict() for item in self.modules],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_erasure_providers(registry: object) -> dict[str, TenantErasureProvider]:
|
||||||
|
capability_names = getattr(registry, "capability_names", None)
|
||||||
|
capability = getattr(registry, "capability", None)
|
||||||
|
if not callable(capability_names) or not callable(capability):
|
||||||
|
raise ValueError("Tenant erasure requires a module registry.")
|
||||||
|
providers: dict[str, TenantErasureProvider] = {}
|
||||||
|
for capability_name in sorted(capability_names()):
|
||||||
|
if not capability_name.startswith(TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX):
|
||||||
|
continue
|
||||||
|
expected_module_id = capability_name.removeprefix(
|
||||||
|
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX
|
||||||
|
)
|
||||||
|
provider = capability(capability_name)
|
||||||
|
if not isinstance(provider, TenantErasureProvider):
|
||||||
|
raise TypeError(
|
||||||
|
f"Tenant erasure provider {expected_module_id or 'unknown'} is invalid."
|
||||||
|
)
|
||||||
|
module_id = _text(provider.module_id, "provider module id", maximum=120)
|
||||||
|
if module_id != expected_module_id or module_id in providers:
|
||||||
|
raise ValueError("Tenant erasure provider identity is invalid.")
|
||||||
|
providers[module_id] = provider
|
||||||
|
return providers
|
||||||
|
|
||||||
|
|
||||||
|
def collect_tenant_erasure_inventory(
|
||||||
|
registry: object,
|
||||||
|
session: object,
|
||||||
|
tenant_id: str,
|
||||||
|
*,
|
||||||
|
observed_at: datetime | None = None,
|
||||||
|
) -> TenantErasureInventory:
|
||||||
|
normalized_tenant_id = _text(tenant_id, "tenant id", maximum=120)
|
||||||
|
manifests = getattr(registry, "manifests", None)
|
||||||
|
summary_providers = getattr(registry, "tenant_summary_providers", None)
|
||||||
|
if not callable(manifests) or not callable(summary_providers):
|
||||||
|
raise ValueError("Tenant erasure inventory requires a module registry.")
|
||||||
|
provider_by_module = tenant_erasure_providers(registry)
|
||||||
|
summary_by_module = dict(summary_providers())
|
||||||
|
manifest_ids = {
|
||||||
|
str(manifest.id)
|
||||||
|
for manifest in manifests()
|
||||||
|
if getattr(manifest, "id", None)
|
||||||
|
}
|
||||||
|
module_ids = manifest_ids | set(summary_by_module) | set(provider_by_module)
|
||||||
|
previews: list[TenantErasurePreview] = []
|
||||||
|
complete = True
|
||||||
|
for module_id in sorted(module_ids):
|
||||||
|
provider = provider_by_module.get(module_id)
|
||||||
|
if provider is not None:
|
||||||
|
try:
|
||||||
|
preview = provider.preview_tenant_erasure(session, normalized_tenant_id)
|
||||||
|
if not isinstance(preview, TenantErasurePreview):
|
||||||
|
raise TypeError("provider returned an invalid preview")
|
||||||
|
if preview.module_id != module_id:
|
||||||
|
raise ValueError("provider returned another module's preview")
|
||||||
|
except Exception as exc:
|
||||||
|
complete = False
|
||||||
|
preview = TenantErasurePreview(
|
||||||
|
module_id=module_id,
|
||||||
|
complete=False,
|
||||||
|
blockers=(
|
||||||
|
f"{type(exc).__name__}: provider preview could not be completed",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
previews.append(preview)
|
||||||
|
complete = complete and preview.complete
|
||||||
|
continue
|
||||||
|
summary_provider = summary_by_module.get(module_id)
|
||||||
|
if summary_provider is None:
|
||||||
|
previews.append(
|
||||||
|
TenantErasurePreview(
|
||||||
|
module_id=module_id,
|
||||||
|
complete=True,
|
||||||
|
warnings=(
|
||||||
|
"Module declares no tenant-owned summary or erasure provider; no tenant persistence is in scope.",
|
||||||
|
),
|
||||||
|
provider_revision="manifest-no-tenant-data",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw_counts = summary_provider(session, normalized_tenant_id)
|
||||||
|
counts = _metrics({str(key): int(value) for key, value in raw_counts.items()})
|
||||||
|
resources = tuple(
|
||||||
|
TenantErasureResource(
|
||||||
|
resource_type=resource_type,
|
||||||
|
count=count,
|
||||||
|
disposition="unavailable" if count else "erase",
|
||||||
|
summary=(
|
||||||
|
"Tenant-owned data requires a module erasure provider."
|
||||||
|
if count
|
||||||
|
else "The module reported no tenant-owned records."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for resource_type, count in sorted(counts.items())
|
||||||
|
)
|
||||||
|
blockers = (
|
||||||
|
("Tenant-owned data exists but the module has no erasure provider.",)
|
||||||
|
if any(counts.values())
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
preview = TenantErasurePreview(
|
||||||
|
module_id=module_id,
|
||||||
|
complete=True,
|
||||||
|
resources=resources,
|
||||||
|
blockers=blockers,
|
||||||
|
provider_revision="tenant-summary-fallback",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
complete = False
|
||||||
|
preview = TenantErasurePreview(
|
||||||
|
module_id=module_id,
|
||||||
|
complete=False,
|
||||||
|
blockers=(
|
||||||
|
f"{type(exc).__name__}: tenant summary could not be completed",
|
||||||
|
),
|
||||||
|
provider_revision="tenant-summary-fallback",
|
||||||
|
)
|
||||||
|
previews.append(preview)
|
||||||
|
timestamp = observed_at or datetime.now(UTC)
|
||||||
|
if timestamp.tzinfo is None:
|
||||||
|
timestamp = timestamp.replace(tzinfo=UTC)
|
||||||
|
return TenantErasureInventory(
|
||||||
|
tenant_id=normalized_tenant_id,
|
||||||
|
generated_at=timestamp,
|
||||||
|
complete=complete,
|
||||||
|
modules=tuple(previews),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX",
|
||||||
|
"TenantErasureInventory",
|
||||||
|
"TenantErasurePreview",
|
||||||
|
"TenantErasureProvider",
|
||||||
|
"TenantErasureResource",
|
||||||
|
"TenantErasureStep",
|
||||||
|
"TenantErasureStepResult",
|
||||||
|
"collect_tenant_erasure_inventory",
|
||||||
|
"tenant_erasure_providers",
|
||||||
|
]
|
||||||
@@ -27,6 +27,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
|||||||
"system:tenants:create": "access:tenant:create",
|
"system:tenants:create": "access:tenant:create",
|
||||||
"system:tenants:update": "access:tenant:update",
|
"system:tenants:update": "access:tenant:update",
|
||||||
"system:tenants:suspend": "access:tenant:suspend",
|
"system:tenants:suspend": "access:tenant:suspend",
|
||||||
|
"system:tenants:erase": "access:tenant:erase",
|
||||||
"system:accounts:read": "access:account:read",
|
"system:accounts:read": "access:account:read",
|
||||||
"system:accounts:create": "access:account:create",
|
"system:accounts:create": "access:account:create",
|
||||||
"system:accounts:update": "access:account:update",
|
"system:accounts:update": "access:account:update",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
|||||||
PermissionDefinition("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
|
PermissionDefinition("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
|
||||||
PermissionDefinition("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
|
PermissionDefinition("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
|
||||||
PermissionDefinition("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
|
PermissionDefinition("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
|
||||||
|
PermissionDefinition("system:tenants:erase", "Erase tenants", "Preview, approve, execute, and reconcile governed destructive tenant erasure.", "System administration", "system"),
|
||||||
PermissionDefinition("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
|
PermissionDefinition("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
|
||||||
PermissionDefinition("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
|
PermissionDefinition("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
|
||||||
PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
|
PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
|
||||||
|
|||||||
@@ -344,6 +344,18 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read"))
|
self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read"))
|
||||||
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
|
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
|
||||||
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
|
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
|
||||||
|
self.assertTrue(
|
||||||
|
scopes_grant_compatible(
|
||||||
|
["access:tenant:erase"],
|
||||||
|
"system:tenants:erase",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
scopes_grant_compatible(
|
||||||
|
["system:tenants:write"],
|
||||||
|
"system:tenants:erase",
|
||||||
|
)
|
||||||
|
)
|
||||||
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
|
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
scopes_grant_compatible(
|
scopes_grant_compatible(
|
||||||
@@ -1015,8 +1027,10 @@ finally:
|
|||||||
json={"mode": "destroy", "reason": "not supported"},
|
json={"mode": "destroy", "reason": "not supported"},
|
||||||
)
|
)
|
||||||
self.assertEqual(409, destructive.status_code, destructive.text)
|
self.assertEqual(409, destructive.status_code, destructive.text)
|
||||||
issue_codes = {item["code"] for item in destructive.json()["detail"]["plan"]["issues"]}
|
self.assertIn(
|
||||||
self.assertIn("tenant_data_present", issue_codes)
|
"Direct destructive deletion is disabled",
|
||||||
|
destructive.json()["detail"]["message"],
|
||||||
|
)
|
||||||
|
|
||||||
with database.session() as session:
|
with database.session() as session:
|
||||||
empty_tenant = Tenant(
|
empty_tenant = Tenant(
|
||||||
@@ -1029,15 +1043,43 @@ finally:
|
|||||||
session.commit()
|
session.commit()
|
||||||
empty_tenant_id = empty_tenant.id
|
empty_tenant_id = empty_tenant.id
|
||||||
|
|
||||||
destroyed = client.request(
|
erasure_policy = client.patch(
|
||||||
"DELETE",
|
"/api/v1/admin/tenant-erasure-policy",
|
||||||
f"/api/v1/admin/tenants/{empty_tenant_id}",
|
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={"mode": "destroy", "reason": "empty tenant cleanup"},
|
json={
|
||||||
|
"production_profile": False,
|
||||||
|
"required_approvals": 1,
|
||||||
|
"preview_ttl_seconds": 900,
|
||||||
|
"recent_authentication_seconds": 900,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(200, destroyed.status_code, destroyed.text)
|
self.assertEqual(200, erasure_policy.status_code, erasure_policy.text)
|
||||||
self.assertEqual("destroy", destroyed.json()["plan"]["action"])
|
erasure_preview = client.post(
|
||||||
self.assertTrue(destroyed.json()["plan"]["destructive_supported"])
|
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"idempotency_key": f"empty-destroy-{name}",
|
||||||
|
"reason": "empty tenant cleanup",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, erasure_preview.status_code, erasure_preview.text)
|
||||||
|
self.assertTrue(erasure_preview.json()["preview"]["allowed"])
|
||||||
|
operation_id = erasure_preview.json()["id"]
|
||||||
|
approved_erasure = client.post(
|
||||||
|
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations/{operation_id}/approve",
|
||||||
|
headers=headers,
|
||||||
|
json={"confirmation": f"empty-destroy-{name}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, approved_erasure.status_code, approved_erasure.text)
|
||||||
|
self.assertEqual("ready", approved_erasure.json()["state"])
|
||||||
|
executed_erasure = client.post(
|
||||||
|
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations/{operation_id}/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={"confirmation": f"empty-destroy-{name}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, executed_erasure.status_code, executed_erasure.text)
|
||||||
|
self.assertEqual("completed", executed_erasure.json()["state"])
|
||||||
|
self.assertIsNone(executed_erasure.json()["reason"])
|
||||||
|
|
||||||
retired = client.request(
|
retired = client.request(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from govoplan_core.core.tenant_erasure import (
|
||||||
|
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
|
||||||
|
TenantErasurePreview,
|
||||||
|
TenantErasureResource,
|
||||||
|
TenantErasureStep,
|
||||||
|
TenantErasureStepResult,
|
||||||
|
collect_tenant_erasure_inventory,
|
||||||
|
tenant_erasure_providers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Provider:
|
||||||
|
module_id = "files"
|
||||||
|
|
||||||
|
def preview_tenant_erasure(self, session, tenant_id: str) -> TenantErasurePreview:
|
||||||
|
del session
|
||||||
|
assert tenant_id == "tenant-1"
|
||||||
|
return TenantErasurePreview(
|
||||||
|
module_id=self.module_id,
|
||||||
|
complete=True,
|
||||||
|
resources=(
|
||||||
|
TenantErasureResource(
|
||||||
|
resource_type="file_blobs",
|
||||||
|
count=2,
|
||||||
|
disposition="erase",
|
||||||
|
summary="Two tenant-owned file blobs will be erased.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
TenantErasureStep(
|
||||||
|
step_id="erase-blobs",
|
||||||
|
kind="erase",
|
||||||
|
summary="Erase tenant-owned file blobs.",
|
||||||
|
destructive=True,
|
||||||
|
irreversible=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute_tenant_erasure_step(
|
||||||
|
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
||||||
|
) -> TenantErasureStepResult:
|
||||||
|
del session, tenant_id, step_id, idempotency_key
|
||||||
|
return TenantErasureStepResult(
|
||||||
|
state="completed",
|
||||||
|
summary="Tenant file blobs erased.",
|
||||||
|
metrics={"deleted": 2},
|
||||||
|
)
|
||||||
|
|
||||||
|
def reconcile_tenant_erasure_step(
|
||||||
|
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
||||||
|
) -> TenantErasureStepResult:
|
||||||
|
return self.execute_tenant_erasure_step(
|
||||||
|
session, tenant_id, step_id, idempotency_key
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, *, provider: object | None = None, counts: dict[str, int] | None = None):
|
||||||
|
self._provider = provider
|
||||||
|
self._counts = counts
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (
|
||||||
|
SimpleNamespace(id="core"),
|
||||||
|
SimpleNamespace(id="files"),
|
||||||
|
SimpleNamespace(id="wiki"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
if self._provider is None:
|
||||||
|
return ()
|
||||||
|
return (f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}files",)
|
||||||
|
|
||||||
|
def capability(self, name: str):
|
||||||
|
assert name.endswith("files")
|
||||||
|
return self._provider
|
||||||
|
|
||||||
|
def tenant_summary_providers(self):
|
||||||
|
if self._counts is None:
|
||||||
|
return {}
|
||||||
|
return {"files": lambda _session, _tenant_id: self._counts}
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_rejects_unsafe_irreversible_step() -> None:
|
||||||
|
with pytest.raises(ValueError, match="must be destructive"):
|
||||||
|
TenantErasureStep(
|
||||||
|
step_id="unsafe",
|
||||||
|
kind="erase",
|
||||||
|
summary="Invalid step.",
|
||||||
|
destructive=False,
|
||||||
|
irreversible=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_rejects_cyclic_step_dependencies() -> None:
|
||||||
|
with pytest.raises(ValueError, match="contain a cycle"):
|
||||||
|
TenantErasurePreview(
|
||||||
|
module_id="files",
|
||||||
|
complete=True,
|
||||||
|
steps=(
|
||||||
|
TenantErasureStep(
|
||||||
|
step_id="first",
|
||||||
|
kind="erase",
|
||||||
|
summary="First.",
|
||||||
|
destructive=True,
|
||||||
|
irreversible=True,
|
||||||
|
depends_on=("second",),
|
||||||
|
),
|
||||||
|
TenantErasureStep(
|
||||||
|
step_id="second",
|
||||||
|
kind="verify",
|
||||||
|
summary="Second.",
|
||||||
|
destructive=False,
|
||||||
|
irreversible=False,
|
||||||
|
depends_on=("first",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_requires_action_or_blocker_for_tenant_data() -> None:
|
||||||
|
resource = TenantErasureResource(
|
||||||
|
resource_type="files",
|
||||||
|
count=1,
|
||||||
|
disposition="erase",
|
||||||
|
summary="One file exists.",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="steps or an explicit blocker"):
|
||||||
|
TenantErasurePreview(
|
||||||
|
module_id="files",
|
||||||
|
complete=True,
|
||||||
|
resources=(resource,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inventory_collects_provider_and_marks_non_data_modules() -> None:
|
||||||
|
inventory = collect_tenant_erasure_inventory(
|
||||||
|
_Registry(provider=_Provider()),
|
||||||
|
object(),
|
||||||
|
"tenant-1",
|
||||||
|
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert inventory.complete
|
||||||
|
assert inventory.allowed
|
||||||
|
assert [item.module_id for item in inventory.modules] == ["core", "files", "wiki"]
|
||||||
|
assert inventory.modules[1].steps[0].irreversible
|
||||||
|
assert inventory.to_dict()["generated_at"] == "2026-08-24T12:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_fallback_blocks_when_data_exists() -> None:
|
||||||
|
inventory = collect_tenant_erasure_inventory(
|
||||||
|
_Registry(counts={"file_blobs": 3}),
|
||||||
|
object(),
|
||||||
|
"tenant-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
files = next(item for item in inventory.modules if item.module_id == "files")
|
||||||
|
assert inventory.complete
|
||||||
|
assert not inventory.allowed
|
||||||
|
assert files.resources[0].disposition == "unavailable"
|
||||||
|
assert files.blockers == (
|
||||||
|
"Tenant-owned data exists but the module has no erasure provider.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_identity_must_match_capability_suffix() -> None:
|
||||||
|
provider = _Provider()
|
||||||
|
provider.module_id = "mail"
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="identity"):
|
||||||
|
tenant_erasure_providers(_Registry(provider=provider))
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.42",
|
"version": "0.1.43",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.42",
|
"version": "0.1.43",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.42",
|
"version": "0.1.43",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.42",
|
"version": "0.1.43",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.23",
|
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24",
|
||||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
|
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
|
||||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20",
|
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20",
|
||||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22",
|
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22",
|
||||||
@@ -757,8 +757,8 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/access-webui": {
|
"node_modules/@govoplan/access-webui": {
|
||||||
"version": "0.1.23",
|
"version": "0.1.24",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#e55434f406e953a2fa9e881abb8fb6dccbabd812",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#0f8a05f8b95340de7e0aa1569a51589764b0776e",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.42",
|
"version": "0.1.43",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.42",
|
"version": "0.1.43",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.23",
|
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24",
|
||||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
|
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
|
||||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20",
|
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20",
|
||||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22",
|
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22",
|
||||||
|
|||||||
Reference in New Issue
Block a user