diff --git a/docs/ACCESS_RBAC_MODEL.md b/docs/ACCESS_RBAC_MODEL.md index 30171a8..74e6b3a 100644 --- a/docs/ACCESS_RBAC_MODEL.md +++ b/docs/ACCESS_RBAC_MODEL.md @@ -142,6 +142,7 @@ system:tenants:read system:tenants:create system:tenants:update system:tenants:suspend +system:tenants:erase system:accounts:read system:accounts:create diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index aa9f7ca..7bfec8d 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -128,6 +128,8 @@ The following contracts are the baseline API that modules can rely on: - bounded reference-option search provider contract - single-tenant and optional batched tenant summary provider contracts - 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 - navigation metadata 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 tenant, independent of ordinary list-page projections. +Governed populated-tenant erasure is separate from ordinary delete vetoes. +Modules contribute `tenancy.erasure_provider.` 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 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 diff --git a/pyproject.toml b/pyproject.toml index cfde941..31e71d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-core" -version = "0.1.42" +version = "0.1.43" description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_core/core/tenant_erasure.py b/src/govoplan_core/core/tenant_erasure.py new file mode 100644 index 0000000..7483503 --- /dev/null +++ b/src/govoplan_core/core/tenant_erasure.py @@ -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", +] diff --git a/src/govoplan_core/security/module_permissions.py b/src/govoplan_core/security/module_permissions.py index 0acbbbc..0510c95 100644 --- a/src/govoplan_core/security/module_permissions.py +++ b/src/govoplan_core/security/module_permissions.py @@ -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", diff --git a/src/govoplan_core/security/permissions.py b/src/govoplan_core/security/permissions.py index af92e0c..d938ac2 100644 --- a/src/govoplan_core/security/permissions.py +++ b/src/govoplan_core/security/permissions.py @@ -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"), diff --git a/tests/test_module_system.py b/tests/test_module_system.py index 61a4b5a..545ddb5 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -344,6 +344,18 @@ class ModuleSystemTests(unittest.TestCase): 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(["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( @@ -1015,8 +1027,10 @@ finally: json={"mode": "destroy", "reason": "not supported"}, ) self.assertEqual(409, destructive.status_code, destructive.text) - issue_codes = {item["code"] for item in destructive.json()["detail"]["plan"]["issues"]} - self.assertIn("tenant_data_present", issue_codes) + self.assertIn( + "Direct destructive deletion is disabled", + destructive.json()["detail"]["message"], + ) with database.session() as session: empty_tenant = Tenant( @@ -1029,15 +1043,43 @@ finally: session.commit() empty_tenant_id = empty_tenant.id - destroyed = client.request( - "DELETE", - f"/api/v1/admin/tenants/{empty_tenant_id}", + erasure_policy = client.patch( + "/api/v1/admin/tenant-erasure-policy", 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("destroy", destroyed.json()["plan"]["action"]) - self.assertTrue(destroyed.json()["plan"]["destructive_supported"]) + self.assertEqual(200, erasure_policy.status_code, erasure_policy.text) + erasure_preview = client.post( + 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( "DELETE", diff --git a/tests/test_tenant_erasure_contract.py b/tests/test_tenant_erasure_contract.py new file mode 100644 index 0000000..6700020 --- /dev/null +++ b/tests/test_tenant_erasure_contract.py @@ -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)) diff --git a/webui/package-lock.json b/webui/package-lock.json index 7a8f4dd..5031182 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.42", + "version": "0.1.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.42", + "version": "0.1.43", "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", diff --git a/webui/package-lock.release.json b/webui/package-lock.release.json index a336ae3..63fd81c 100644 --- a/webui/package-lock.release.json +++ b/webui/package-lock.release.json @@ -1,14 +1,14 @@ { "name": "@govoplan/core-webui", - "version": "0.1.42", + "version": "0.1.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.42", + "version": "0.1.43", "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/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", @@ -757,8 +757,8 @@ "optional": true }, "node_modules/@govoplan/access-webui": { - "version": "0.1.23", - "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#e55434f406e953a2fa9e881abb8fb6dccbabd812", + "version": "0.1.24", + "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#0f8a05f8b95340de7e0aa1569a51589764b0776e", "peerDependencies": { "@govoplan/core-webui": "^0.1.18", "lucide-react": "^1.23.0", diff --git a/webui/package.json b/webui/package.json index dc84e04..c9fa740 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.42", + "version": "0.1.43", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/package.release.json b/webui/package.release.json index a9564dd..3050e08 100644 --- a/webui/package.release.json +++ b/webui/package.release.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.42", + "version": "0.1.43", "private": true, "type": "module", "main": "src/index.ts", @@ -26,7 +26,7 @@ "preview": "vite preview --host 127.0.0.1 --port 4173" }, "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/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",