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:
@@ -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:update": "access:tenant:update",
|
||||
"system:tenants:suspend": "access:tenant:suspend",
|
||||
"system:tenants:erase": "access:tenant:erase",
|
||||
"system:accounts:read": "access:account:read",
|
||||
"system:accounts:create": "access:account:create",
|
||||
"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: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: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:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
|
||||
PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
|
||||
|
||||
Reference in New Issue
Block a user