12 Commits
Author SHA1 Message Date
zemion 6bae34263b fix(ui): clarify inherited navigation and heading help
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:04:23 +02:00
zemion 9664b0283b Release govoplan-tenancy v0.1.22: unify interface contracts and documentation
Module Package Release / publish-packages (push) Successful in 10s
2026-09-08 01:32:54 +02:00
zemion bfc38a7bc9 feat: cover tenant erasure evidence in DSAR
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 16:07:38 +02:00
zemion e412c7d1bd feat: orchestrate governed tenant erasure 2026-08-24 15:57:21 +02:00
zemion 9ed32618ea docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:41 +02:00
zemion 2cac95fa3e docs(tenancy): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:27:26 +02:00
zemion c558621550 feat: govern tenant appearance overrides 2026-08-20 10:50:55 +02:00
zemion c5119ab868 feat: manage tenant appearance defaults 2026-08-20 07:03:28 +02:00
zemion 56f0661a10 feat(tenancy): add tenant navigation overrides 2026-08-20 01:47:04 +02:00
zemion c51ea3f66c Adopt shared WebUI layout primitives 2026-08-18 11:30:40 +02:00
zemion 5d49483369 Adopt shared WebUI layout primitives 2026-08-18 10:42:54 +02:00
zemion 4687e9e0d3 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:46 +02:00
25 changed files with 2704 additions and 124 deletions
+6
View File
@@ -26,6 +26,12 @@ pattern contract documented in
Manifest-provided documentation topics back contextual help for tenant fields,
governance limits, permission blockers, and lifecycle consequences.
Destructive tenant erasure is an explicit, durable workflow rather than a
single delete request. Provider previews, policy-defined multi-party approval,
recent authentication, typed confirmation, suspension, idempotent checkpoints,
and reconciliation must all succeed before the Core scope is removed. See
`docs/TENANCY_MODULE_BOUNDARY.md` for the API and recovery contract.
Core's `module_entitlements` tenant-setting key is reserved. Generic tenant
updates preserve it even when replacing the remaining settings document;
system and tenant module administrators change it through the Admin module's
+43 -4
View File
@@ -19,10 +19,40 @@ compatibility, but both routes delegate to the same
Tenant retirement is non-destructive. It marks the tenant inactive and stores
lifecycle metadata in tenant settings.
Destructive deletion is intentionally narrow: it is allowed only when the
tenant is not the caller's active tenant and all registered tenant-owned counts
are zero. Populated tenants must be retired first or cleaned explicitly by
their owning modules before physical deletion.
The compatibility `DELETE /api/v1/admin/tenants/{tenant_id}` route is
non-destructive retirement only. Requests with `mode=destroy` fail with a link
to the governed erasure-operation API; even an apparently empty scope must not
bypass recent authentication, typed confirmation, approval, and durable
evidence.
Populated-tenant erasure uses `/erasure-operations` instead. A durable
operation stores a non-secret, digest-bound provider preview, policy snapshot,
distinct approvals, and per-step checkpoints. The safe default production
policy requires two distinct approvals, a preview no older than fifteen
minutes, recent interactive authentication, the dedicated
`system:tenants:erase` permission, and exact tenant-slug confirmation. The
policy is configurable through `tenant_erasure_policy` in system settings;
production profiles cannot reduce approval below two.
Authorized system administrators read and update it through
`GET/PATCH /api/v1/admin/tenant-erasure-policy`; policy changes require both
`system:tenants:erase` and `system:settings:write` plus recent interactive
authentication.
Execution verifies the preview again, suspends tenant access, and commits a
checkpoint before each module effect. A timeout or unknown/pending outcome
stops for reconciliation using the same provider idempotency key. Cancellation
is available only before destructive work starts. The Core scope is deleted
only after providers finish, a fresh inventory is clear, and delete vetoes and
tenant counts are zero. The durable operation then removes its free-text reason
and never stores typed confirmation, secrets, or erased tenant content.
The module's `privacy.dsar.tenancy` provider returns bounded requester and
approver roles, approval timestamps, operation state, and an unfinished
request reason only to the corroborated account selector. These actor and
checkpoint references are immutable authorization, separation-of-duties, and
recovery evidence, so the provider returns an explicit non-executable retain
action. Typed confirmation and credentials are never persisted; free-text
reason is removed when the tenant-erasure operation completes.
Tenant lifecycle planning uses registered tenant summary providers and delete
veto providers. Modules that own tenant-scoped data must contribute summaries
@@ -35,6 +65,15 @@ exception are treated as blocking module vetoes. Tenancy exposes those issues in
the deletion plan with module attribution and resource details, so operators can
see which module blocks or qualifies the lifecycle action.
Core's `tenancy.erasure_provider.<module_id>` contract supplies module-owned
resource dispositions, irreversible warnings, ordered steps, idempotent
execution, and outcome reconciliation. An installed module with nonzero tenant
summary counts but no erasure provider explicitly blocks the operation. A
module that declares neither a tenant summary nor an erasure provider is
reported as outside tenant-persistence scope rather than silently executed.
Access provides the first concrete contribution and retains shared global
accounts and identities while erasing target-tenant authorization records.
## Lifecycle Events
`govoplan-tenancy.backend.lifecycle` is the module-local contract for tenant
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/tenancy-webui",
"version": "0.1.17",
"version": "0.1.22",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -17,7 +17,7 @@
"README.md"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.17",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-tenancy"
version = "0.1.17"
version = "0.1.22"
description = "GovOPlaN tenancy platform module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.17",
"govoplan-core>=0.1.45",
]
[tool.setuptools.packages.find]
+685 -68
View File
@@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, or_
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from govoplan_core.admin.common import AdminValidationError, slugify
@@ -18,9 +18,22 @@ from govoplan_core.core.access import (
TenantContextSwitcher,
)
from govoplan_core.core.change_sequence import ChangeSequenceEntry, decode_sequence_watermark, encode_sequence_watermark, record_change, sequence_watermark_is_expired
from govoplan_core.core.appearance import (
APPEARANCE_SETTINGS_KEY,
appearance_custom_overrides_policy,
appearance_settings,
resolve_effective_appearance,
update_appearance_custom_overrides_policy,
update_appearance_settings,
)
from govoplan_core.core.module_entitlements import MODULE_ENTITLEMENTS_KEY
from govoplan_core.core.navigation import (
navigation_preferences_from_settings,
update_navigation_preferences,
)
from govoplan_core.core.principal_cache import invalidate_auth_principals
from govoplan_core.core.runtime import get_registry
from govoplan_core.core.tenant_erasure import collect_tenant_erasure_inventory
from govoplan_core.db.session import get_session
from govoplan_core.i18n import (
REFERENCE_LANGUAGE_CODE,
@@ -38,7 +51,21 @@ from govoplan_core.tenancy.service import (
tenant_counts,
tenant_counts_many,
)
from govoplan_tenancy.backend.db.models import Tenant
from govoplan_tenancy.backend.db.models import Tenant, TenantErasureOperation
from govoplan_tenancy.backend.erasure import (
TENANT_ERASURE_POLICY_KEY,
TenantErasureConflict,
TenantErasurePolicy,
approve_tenant_erasure_operation,
assert_tenant_erasure_executable,
cancel_tenant_erasure_operation,
complete_tenant_erasure_operation,
create_tenant_erasure_operation,
run_tenant_erasure_steps,
tenant_erasure_recently_authenticated,
tenant_erasure_policy,
verify_tenant_erasure_preview,
)
from govoplan_tenancy.backend.lifecycle import (
TENANT_EVENT_CREATED,
TENANT_EVENT_DELETION_REQUESTED,
@@ -54,6 +81,12 @@ from .schemas import (
TenantCreateRequest,
TenantDeleteRequest,
TenantDeletionPlanResponse,
TenantErasureApprovalRequest,
TenantErasureExecutionRequest,
TenantErasureOperationResponse,
TenantErasurePolicyResponse,
TenantErasurePolicyUpdateRequest,
TenantErasurePreviewRequest,
TenantLifecycleIssue,
TenantLifecycleResponse,
TenantListDeltaResponse,
@@ -76,7 +109,7 @@ TENANT_SETTINGS_COLLECTION = "tenancy.tenant_settings"
TENANT_SETTINGS_RESOURCE = "tenant_settings_section"
ADMIN_MODULE_ID = "admin"
ADMIN_SYSTEM_SETTINGS_COLLECTION = "admin.system_settings"
TENANT_SETTINGS_SECTIONS = ("identity", "locale", "languages", "settings")
TENANT_SETTINGS_SECTIONS = ("identity", "locale", "languages", "navigation", "appearance", "settings")
TENANT_NON_STATUS_UPDATE_FIELDS = {
"name",
"description",
@@ -251,6 +284,16 @@ def _tenant_settings_item(session: Session, tenant: Tenant) -> TenantSettingsIte
system_payload = system_i18n_payload(system_settings)
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale)
navigation = navigation_preferences_from_settings(tenant.settings)
system_palette, system_locked = appearance_settings(system_settings.settings)
tenant_palette, tenant_locked = appearance_settings(tenant.settings)
system_custom_overrides_allowed = appearance_custom_overrides_policy(system_settings.settings) is True
tenant_custom_overrides_allowed = appearance_custom_overrides_policy(tenant.settings)
effective_appearance = resolve_effective_appearance(
system_settings=system_settings.settings,
tenant_settings=tenant.settings,
user_settings={},
)
return TenantSettingsItem(
id=tenant.id,
slug=tenant.slug,
@@ -259,6 +302,16 @@ def _tenant_settings_item(session: Session, tenant: Tenant) -> TenantSettingsIte
available_languages=system_payload["available_languages"],
system_enabled_language_codes=system_enabled,
enabled_language_codes=enabled,
navigation=navigation.as_dict() if navigation is not None else None,
appearance_palette=tenant_palette,
appearance_palette_locked=tenant_locked,
system_appearance_palette=system_palette or "default",
system_appearance_palette_locked=system_locked,
effective_appearance_palette=effective_appearance.palette,
effective_appearance_source=effective_appearance.source,
appearance_custom_overrides_allowed=tenant_custom_overrides_allowed,
system_appearance_custom_overrides_allowed=system_custom_overrides_allowed,
effective_appearance_custom_overrides_allowed=effective_appearance.custom_overrides_allowed,
settings=tenant.settings or {},
)
@@ -273,6 +326,18 @@ def _tenant_settings_sections(item: TenantSettingsItem) -> dict[str, Any]:
"system_enabled_language_codes": payload["system_enabled_language_codes"],
"enabled_language_codes": payload["enabled_language_codes"],
},
"navigation": payload["navigation"],
"appearance": {
"appearance_palette": payload["appearance_palette"],
"appearance_palette_locked": payload["appearance_palette_locked"],
"system_appearance_palette": payload["system_appearance_palette"],
"system_appearance_palette_locked": payload["system_appearance_palette_locked"],
"effective_appearance_palette": payload["effective_appearance_palette"],
"effective_appearance_source": payload["effective_appearance_source"],
"appearance_custom_overrides_allowed": payload["appearance_custom_overrides_allowed"],
"system_appearance_custom_overrides_allowed": payload["system_appearance_custom_overrides_allowed"],
"effective_appearance_custom_overrides_allowed": payload["effective_appearance_custom_overrides_allowed"],
},
"settings": payload["settings"],
}
@@ -621,7 +686,7 @@ def create_tenant(
name=payload.name.strip(),
description=payload.description.strip() if payload.description else None,
default_locale=payload.default_locale.strip() or system_defaults.default_locale,
settings=payload.settings,
settings={key: value for key, value in payload.settings.items() if key != APPEARANCE_SETTINGS_KEY},
allow_custom_groups=payload.allow_custom_groups,
allow_custom_roles=payload.allow_custom_roles,
allow_api_keys=payload.allow_api_keys,
@@ -671,11 +736,10 @@ def _apply_tenant_content_updates(tenant: Tenant, payload: TenantUpdateRequest)
if payload.settings is not None:
current_settings = dict(tenant.settings or {})
next_settings = dict(payload.settings)
next_settings.pop(MODULE_ENTITLEMENTS_KEY, None)
if MODULE_ENTITLEMENTS_KEY in current_settings:
next_settings[MODULE_ENTITLEMENTS_KEY] = current_settings[
MODULE_ENTITLEMENTS_KEY
]
for reserved_key in (MODULE_ENTITLEMENTS_KEY, APPEARANCE_SETTINGS_KEY):
next_settings.pop(reserved_key, None)
if reserved_key in current_settings:
next_settings[reserved_key] = current_settings[reserved_key]
tenant.settings = next_settings
@@ -775,6 +839,540 @@ def tenant_deletion_plan(
return _tenant_deletion_plan(session, tenant, principal)
def _tenant_erasure_registry():
registry = get_registry()
if registry is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Tenant erasure provider registry is unavailable.",
)
return registry
def _tenant_erasure_operation_or_404(
session: Session,
tenant_id: str,
operation_id: str,
*,
for_update: bool = False,
) -> TenantErasureOperation:
if for_update:
operation = session.execute(
select(TenantErasureOperation)
.where(TenantErasureOperation.id == operation_id)
.with_for_update()
).scalar_one_or_none()
else:
operation = session.get(TenantErasureOperation, operation_id)
if operation is None or operation.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant erasure operation not found",
)
return operation
def _tenant_erasure_response(
operation: TenantErasureOperation,
) -> TenantErasureOperationResponse:
return TenantErasureOperationResponse.model_validate(
{
field: getattr(operation, field)
for field in TenantErasureOperationResponse.model_fields
}
)
def _require_recent_tenant_erasure_authentication(
principal: ApiPrincipal,
operation: TenantErasureOperation,
) -> None:
if not tenant_erasure_recently_authenticated(
principal.auth_session,
operation.policy,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant erasure requires a recently authenticated interactive session.",
)
def _require_current_tenant_erasure_policy(
session: Session,
operation: TenantErasureOperation,
) -> None:
try:
current = tenant_erasure_policy(session).to_dict()
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
if current != dict(operation.policy or {}):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Tenant erasure policy changed; create and approve a fresh preview.",
)
@router.get(
"/tenant-erasure-policy",
response_model=TenantErasurePolicyResponse,
)
def get_tenant_erasure_policy(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:erase")),
):
del principal
try:
return TenantErasurePolicyResponse.model_validate(
tenant_erasure_policy(session).to_dict()
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
@router.patch(
"/tenant-erasure-policy",
response_model=TenantErasurePolicyResponse,
)
def update_tenant_erasure_policy(
payload: TenantErasurePolicyUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:erase")),
):
_require_permission(principal, "system:settings:write")
current = tenant_erasure_policy(session)
if not tenant_erasure_recently_authenticated(
principal.auth_session,
current,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Changing tenant erasure policy requires a recently authenticated interactive session.",
)
try:
updated = TenantErasurePolicy(**payload.model_dump())
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
item = get_system_settings(session)
settings_payload = dict(item.settings or {})
settings_payload[TENANT_ERASURE_POLICY_KEY] = updated.to_dict()
item.settings = settings_payload
session.add(item)
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
action="tenant.erasure_policy_updated",
scope="system",
object_type="system_settings",
object_id="global",
details=updated.to_dict(),
)
session.commit()
return TenantErasurePolicyResponse.model_validate(updated.to_dict())
@router.post(
"/tenants/{tenant_id}/erasure-operations",
response_model=TenantErasureOperationResponse,
status_code=status.HTTP_201_CREATED,
)
def preview_tenant_erasure(
tenant_id: str,
payload: TenantErasurePreviewRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:erase")),
):
tenant = _tenant_or_404(session, tenant_id)
registry = _tenant_erasure_registry()
try:
operation, replayed = create_tenant_erasure_operation(
session,
registry=registry,
tenant_id=tenant.id,
idempotency_key=payload.idempotency_key,
requested_by_account_id=principal.account_id,
reason=payload.reason,
)
except (TenantErasureConflict, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
if not replayed:
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="tenant.erasure_previewed",
scope="system",
object_type="tenant_erasure_operation",
object_id=operation.id,
details={
"preview_digest": operation.preview_digest,
"allowed": bool((operation.preview or {}).get("allowed")),
"expires_at": operation.preview_expires_at.isoformat(),
},
)
session.commit()
return _tenant_erasure_response(operation)
@router.get(
"/tenants/{tenant_id}/erasure-operations/{operation_id}",
response_model=TenantErasureOperationResponse,
)
def get_tenant_erasure_operation(
tenant_id: str,
operation_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:erase")),
):
del principal
return _tenant_erasure_response(
_tenant_erasure_operation_or_404(session, tenant_id, operation_id)
)
@router.post(
"/tenants/{tenant_id}/erasure-operations/{operation_id}/approve",
response_model=TenantErasureOperationResponse,
)
def approve_tenant_erasure(
tenant_id: str,
operation_id: str,
payload: TenantErasureApprovalRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:erase")),
):
tenant = _tenant_or_404(session, tenant_id)
operation = _tenant_erasure_operation_or_404(
session,
tenant_id,
operation_id,
for_update=True,
)
_require_current_tenant_erasure_policy(session, operation)
_require_recent_tenant_erasure_authentication(principal, operation)
try:
replayed = approve_tenant_erasure_operation(
operation,
account_id=principal.account_id,
confirmation=payload.confirmation,
tenant_slug=tenant.slug,
)
except TenantErasureConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
if not replayed:
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="tenant.erasure_approved",
scope="system",
object_type="tenant_erasure_operation",
object_id=operation.id,
details={
"approval_count": len(operation.approvals or []),
"required_approvals": int(
(operation.policy or {}).get("required_approvals", 2)
),
},
)
session.add(operation)
session.commit()
return _tenant_erasure_response(operation)
@router.post(
"/tenants/{tenant_id}/erasure-operations/{operation_id}/cancel",
response_model=TenantErasureOperationResponse,
)
def cancel_tenant_erasure(
tenant_id: str,
operation_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:erase")),
):
operation = _tenant_erasure_operation_or_404(
session,
tenant_id,
operation_id,
for_update=True,
)
_require_recent_tenant_erasure_authentication(principal, operation)
try:
cancel_tenant_erasure_operation(operation)
except TenantErasureConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
audit_event(
session,
tenant_id=tenant_id,
user_id=principal.user.id,
action="tenant.erasure_cancelled",
scope="system",
object_type="tenant_erasure_operation",
object_id=operation.id,
details={"destructive_started": operation.destructive_started},
)
session.add(operation)
session.commit()
return _tenant_erasure_response(operation)
def _execute_tenant_erasure(
*,
tenant_id: str,
operation_id: str,
payload: TenantErasureExecutionRequest,
session: Session,
principal: ApiPrincipal,
reconcile: bool,
) -> TenantErasureOperationResponse:
tenant = _tenant_or_404(session, tenant_id)
if tenant.id == principal.tenant_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Switch to another tenant before executing tenant erasure.",
)
operation = _tenant_erasure_operation_or_404(
session,
tenant_id,
operation_id,
for_update=True,
)
if not reconcile:
_require_current_tenant_erasure_policy(session, operation)
_require_recent_tenant_erasure_authentication(principal, operation)
if reconcile:
if operation.state not in {"running", "reconciliation_required"}:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"Tenant erasure reconciliation requires a running or "
"reconciliation-required operation."
),
)
operation_updated_at = operation.updated_at
current_time = utc_now()
if operation_updated_at.tzinfo is None:
operation_updated_at = operation_updated_at.replace(
tzinfo=current_time.tzinfo
)
if operation.state == "running" and (
current_time - operation_updated_at
).total_seconds() < 60:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Tenant erasure is still running; wait before taking over reconciliation.",
)
elif operation.state != "ready":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Tenant erasure execution requires a fully approved ready operation.",
)
try:
assert_tenant_erasure_executable(
operation,
confirmation=payload.confirmation,
tenant_slug=tenant.slug,
)
except TenantErasureConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
registry = _tenant_erasure_registry()
if not reconcile:
try:
preview_matches = verify_tenant_erasure_preview(
session,
registry=registry,
operation=operation,
)
except (TenantErasureConflict, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
if not preview_matches:
operation.state = "blocked"
operation.last_error = "Tenant state changed; create and approve a fresh erasure preview."
operation.revision += 1
session.add(operation)
session.commit()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=operation.last_error,
)
if tenant.is_active:
tenant.is_active = False
session.add(tenant)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action=tenant_lifecycle_event_type("suspended"),
scope="system",
object_type="tenant",
object_id=tenant.id,
details=tenant_lifecycle_event(
"suspended",
tenant_id=tenant.id,
actor_account_id=principal.account_id,
details={
"reason_code": "erasure_execution",
"operation_id": operation.id,
},
).audit_details(),
)
_record_tenant_list_change(
session,
tenant=tenant,
operation="updated",
principal=principal,
)
session.commit()
providers_complete = run_tenant_erasure_steps(
session,
registry=registry,
operation=operation,
)
if not providers_complete:
return _tenant_erasure_response(operation)
final_inventory = collect_tenant_erasure_inventory(
registry,
session,
tenant.id,
)
final_plan = _tenant_deletion_plan(
session,
tenant,
principal,
mode="destroy",
)
if not final_inventory.allowed or not final_plan.allowed:
operation.state = "reconciliation_required"
operation.last_error = (
"Provider execution finished, but final tenant inventory or delete vetoes remain unresolved."
)
operation.revision += 1
session.add(operation)
session.commit()
return _tenant_erasure_response(operation)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action=TENANT_EVENT_DELETION_REQUESTED,
scope="system",
object_type="tenant",
object_id=tenant.id,
details=tenant_lifecycle_event(
"deletion_requested",
tenant_id=tenant.id,
actor_account_id=principal.account_id,
counts=final_plan.counts,
details={
"mode": "orchestrated_erasure",
"operation_id": operation.id,
"preview_digest": operation.preview_digest,
},
).audit_details(),
)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action=TENANT_EVENT_ERASURE_COMPLETED,
scope="system",
object_type="tenant",
object_id=tenant.id,
details=tenant_lifecycle_event(
"erasure_completed",
tenant_id=tenant.id,
actor_account_id=principal.account_id,
counts=final_plan.counts,
details={
"mode": "orchestrated_erasure",
"operation_id": operation.id,
"preview_digest": operation.preview_digest,
},
).audit_details(),
)
_record_tenant_list_change(
session,
tenant=tenant,
operation="deleted",
principal=principal,
)
complete_tenant_erasure_operation(operation)
session.add(operation)
session.delete(tenant)
session.commit()
return _tenant_erasure_response(operation)
@router.post(
"/tenants/{tenant_id}/erasure-operations/{operation_id}/execute",
response_model=TenantErasureOperationResponse,
)
def execute_tenant_erasure(
tenant_id: str,
operation_id: str,
payload: TenantErasureExecutionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:erase")),
):
return _execute_tenant_erasure(
tenant_id=tenant_id,
operation_id=operation_id,
payload=payload,
session=session,
principal=principal,
reconcile=False,
)
@router.post(
"/tenants/{tenant_id}/erasure-operations/{operation_id}/reconcile",
response_model=TenantErasureOperationResponse,
)
def reconcile_tenant_erasure(
tenant_id: str,
operation_id: str,
payload: TenantErasureExecutionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:erase")),
):
return _execute_tenant_erasure(
tenant_id=tenant_id,
operation_id=operation_id,
payload=payload,
session=session,
principal=principal,
reconcile=True,
)
@router.delete("/tenants/{tenant_id}", response_model=TenantLifecycleResponse)
def retire_tenant(
tenant_id: str,
@@ -786,6 +1384,19 @@ def retire_tenant(
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
if request.mode == "destroy":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": (
"Direct destructive deletion is disabled; create, approve, and "
"execute a tenant erasure operation."
),
"erasure_operations_path": (
f"/api/v1/admin/tenants/{tenant.id}/erasure-operations"
),
},
)
plan = _tenant_deletion_plan(session, tenant, principal, mode=request.mode)
if not plan.allowed:
action_label = "destroyed" if request.mode == "destroy" else "retired"
@@ -794,62 +1405,6 @@ def retire_tenant(
detail={"message": f"Tenant cannot be {action_label}.", "plan": plan.model_dump(mode="json")},
)
if request.mode == "destroy":
deleted_item = _tenant_item(session, tenant)
deletion_event = tenant_lifecycle_event(
"deletion_requested",
tenant_id=tenant.id,
tenant_slug=tenant.slug,
tenant_name=tenant.name,
actor_account_id=principal.account_id,
reason=request.reason,
counts=plan.counts,
details={"mode": request.mode},
)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action=TENANT_EVENT_DELETION_REQUESTED,
scope="system",
object_type="tenant",
object_id=tenant.id,
details=deletion_event.audit_details(),
)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="tenant.destroyed",
scope="system",
object_type="tenant",
object_id=tenant.id,
details={"reason": request.reason, "counts": plan.counts},
)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action=TENANT_EVENT_ERASURE_COMPLETED,
scope="system",
object_type="tenant",
object_id=tenant.id,
details=tenant_lifecycle_event(
"erasure_completed",
tenant_id=tenant.id,
tenant_slug=tenant.slug,
tenant_name=tenant.name,
actor_account_id=principal.account_id,
reason=request.reason,
counts=plan.counts,
details={"mode": request.mode},
).audit_details(),
)
_record_tenant_list_change(session, tenant=tenant, operation="deleted", principal=principal)
session.delete(tenant)
session.commit()
return TenantLifecycleResponse(item=deleted_item, plan=plan)
settings_payload = dict(tenant.settings or {})
lifecycle_payload = dict(settings_payload.get("lifecycle") or {})
lifecycle_payload.update({
@@ -938,8 +1493,8 @@ def get_tenant_settings_delta(
return _full_tenant_settings_delta_response(session, tenant)
changed = set()
for entry in entries:
if entry.module_id == ADMIN_MODULE_ID and entry.collection == ADMIN_SYSTEM_SETTINGS_COLLECTION and entry.resource_id == "languages":
changed.add("languages")
if entry.module_id == ADMIN_MODULE_ID and entry.collection == ADMIN_SYSTEM_SETTINGS_COLLECTION and entry.resource_id in {"languages", "appearance"}:
changed.add(entry.resource_id)
elif entry.resource_type == TENANT_SETTINGS_RESOURCE:
changed.add(entry.resource_id)
changed_sections = [section for section in TENANT_SETTINGS_SECTIONS if section in changed]
@@ -967,6 +1522,39 @@ def update_tenant_settings(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
before_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant))
system_settings = get_system_settings(session)
tenant_palette, tenant_locked = appearance_settings(tenant.settings)
system_palette, system_locked = appearance_settings(system_settings.settings)
system_custom_overrides_allowed = appearance_custom_overrides_policy(system_settings.settings) is True
tenant_custom_overrides_allowed = appearance_custom_overrides_policy(tenant.settings)
appearance_palette_changed = (
"appearance_palette" in payload.model_fields_set
and payload.appearance_palette != tenant_palette
)
appearance_lock_changed = (
"appearance_palette_locked" in payload.model_fields_set
and payload.appearance_palette_locked != tenant_locked
)
custom_overrides_policy_changed = (
"appearance_custom_overrides_allowed" in payload.model_fields_set
and payload.appearance_custom_overrides_allowed != tenant_custom_overrides_allowed
)
if payload.appearance_custom_overrides_allowed is True and not system_custom_overrides_allowed:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="The system appearance policy does not allow personal custom overrides.",
)
if system_locked and (appearance_palette_changed or appearance_lock_changed):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"The system appearance policy locks palette {system_palette or 'default'}.",
)
if (
appearance_lock_changed or (tenant_locked and appearance_palette_changed) or custom_overrides_policy_changed
) and not has_scope(principal, "admin:policies:write"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Changing the tenant appearance policy requires admin:policies:write.",
)
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
current_i18n = i18n_settings(tenant.settings)
raw_enabled = payload.enabled_language_codes if "enabled_language_codes" in payload.model_fields_set else current_i18n.get("enabled_language_codes")
@@ -978,6 +1566,23 @@ def update_tenant_settings(
)
tenant.default_locale = payload.default_locale.strip() or enabled[0]
tenant.settings = update_i18n_settings(tenant.settings, enabled_language_codes=enabled)
if {"appearance_palette", "appearance_palette_locked"}.intersection(payload.model_fields_set):
current_palette, current_locked = appearance_settings(tenant.settings)
tenant.settings = update_appearance_settings(
tenant.settings,
default_palette=payload.appearance_palette if "appearance_palette" in payload.model_fields_set else current_palette,
palette_locked=payload.appearance_palette_locked if payload.appearance_palette_locked is not None else current_locked,
)
if "appearance_custom_overrides_allowed" in payload.model_fields_set:
tenant.settings = update_appearance_custom_overrides_policy(
tenant.settings,
allowed=payload.appearance_custom_overrides_allowed,
)
if "navigation" in payload.model_fields_set:
tenant.settings = update_navigation_preferences(
tenant.settings,
payload.navigation.model_dump(mode="json") if payload.navigation else None,
)
session.add(tenant)
audit_event(
session,
@@ -986,7 +1591,19 @@ def update_tenant_settings(
action="tenant.settings.updated",
object_type="tenant",
object_id=tenant.id,
details={"default_locale": tenant.default_locale, "enabled_language_codes": enabled},
details={
"default_locale": tenant.default_locale,
"enabled_language_codes": enabled,
"navigation_updated": "navigation" in payload.model_fields_set,
"appearance_updated": bool(
{"appearance_palette", "appearance_palette_locked", "appearance_custom_overrides_allowed"}.intersection(
payload.model_fields_set
)
),
"appearance_palette": appearance_settings(tenant.settings)[0],
"appearance_palette_locked": appearance_settings(tenant.settings)[1],
"appearance_custom_overrides_allowed": appearance_custom_overrides_policy(tenant.settings),
},
)
after_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant))
_record_tenant_settings_section_changes(session, tenant_id=tenant.id, before=before_sections, after=after_sections, principal=principal)
+75 -1
View File
@@ -5,7 +5,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.api.v1.schemas import DeltaDeletedItem, NavigationPreferencesPayload
from govoplan_core.i18n import REFERENCE_LANGUAGE_CODE
@@ -112,6 +112,66 @@ class TenantLifecycleResponse(BaseModel):
plan: TenantDeletionPlanResponse
class TenantErasurePreviewRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
idempotency_key: str = Field(min_length=8, max_length=160)
reason: str | None = Field(default=None, max_length=2000)
class TenantErasureApprovalRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
confirmation: str = Field(min_length=1, max_length=100)
class TenantErasureExecutionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
confirmation: str = Field(min_length=1, max_length=100)
class TenantErasurePolicyResponse(BaseModel):
production_profile: bool
required_approvals: int = Field(ge=1, le=10)
preview_ttl_seconds: int = Field(ge=60, le=86400)
recent_authentication_seconds: int = Field(ge=60, le=86400)
class TenantErasurePolicyUpdateRequest(TenantErasurePolicyResponse):
model_config = ConfigDict(extra="forbid")
class TenantErasureOperationResponse(BaseModel):
id: str
tenant_id: str
state: Literal[
"awaiting_approval",
"blocked",
"ready",
"running",
"reconciliation_required",
"cancelled",
"completed",
]
preview: dict[str, Any]
preview_digest: str
previewed_at: datetime
preview_expires_at: datetime
policy: TenantErasurePolicyResponse
approvals: list[dict[str, Any]] = Field(default_factory=list)
steps: list[dict[str, Any]] = Field(default_factory=list)
requested_by_account_id: str
reason: str | None = None
destructive_started: bool
revision: int
started_at: datetime | None = None
completed_at: datetime | None = None
last_error: str | None = None
created_at: datetime
updated_at: datetime
class TenantContextSwitchRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -137,6 +197,16 @@ class TenantSettingsItem(BaseModel):
available_languages: list[dict[str, Any]] = Field(default_factory=list)
system_enabled_language_codes: list[str] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
navigation: NavigationPreferencesPayload | None = None
appearance_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
appearance_palette_locked: bool = False
system_appearance_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
system_appearance_palette_locked: bool = False
effective_appearance_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
effective_appearance_source: Literal["tenant", "system", "tenant_lock", "system_lock"] = "system"
appearance_custom_overrides_allowed: bool | None = None
system_appearance_custom_overrides_allowed: bool = False
effective_appearance_custom_overrides_allowed: bool = False
settings: dict[str, Any] = Field(default_factory=dict)
@@ -155,3 +225,7 @@ class TenantSettingsUpdateRequest(BaseModel):
default_locale: str = Field(min_length=1, max_length=20)
enabled_language_codes: list[str] | None = None
navigation: NavigationPreferencesPayload | None = None
appearance_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
appearance_palette_locked: bool | None = None
appearance_custom_overrides_allowed: bool | None = None
+48 -1
View File
@@ -1,6 +1,53 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
from govoplan_core.tenancy.scope import Tenant, new_uuid
__all__ = ["Tenant", "new_uuid"]
class TenantErasureOperation(Base, TimestampMixin):
__tablename__ = "tenancy_erasure_operations"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_tenancy_erasure_tenant_idempotency",
),
Index(
"ix_tenancy_erasure_tenant_state",
"tenant_id",
"state",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=new_uuid,
)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
preview_digest: Mapped[str] = mapped_column(String(64), nullable=False)
preview: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
previewed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
preview_expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
approvals: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
steps: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
requested_by_account_id: Mapped[str] = mapped_column(String(36), nullable=False)
reason: Mapped[str | None] = mapped_column(Text)
destructive_started: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
last_error: Mapped[str | None] = mapped_column(Text)
__all__ = ["Tenant", "TenantErasureOperation", "new_uuid"]
@@ -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"]
+500
View File
@@ -0,0 +1,500 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import hashlib
import json
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.core.tenant_erasure import (
TenantErasureInventory,
TenantErasureStepResult,
collect_tenant_erasure_inventory,
tenant_erasure_providers,
)
from govoplan_core.security.time import utc_now
from govoplan_tenancy.backend.db.models import TenantErasureOperation
TENANT_ERASURE_POLICY_KEY = "tenant_erasure_policy"
TERMINAL_ERASURE_STATES = frozenset({"cancelled", "completed"})
class TenantErasureConflict(ValueError):
pass
@dataclass(frozen=True, slots=True)
class TenantErasurePolicy:
production_profile: bool = True
required_approvals: int = 2
preview_ttl_seconds: int = 900
recent_authentication_seconds: int = 900
def __post_init__(self) -> None:
if not 1 <= self.required_approvals <= 10:
raise ValueError("Tenant erasure required approvals must be between 1 and 10.")
if not 60 <= self.preview_ttl_seconds <= 86_400:
raise ValueError("Tenant erasure preview lifetime must be between 60 seconds and one day.")
if not 60 <= self.recent_authentication_seconds <= 86_400:
raise ValueError("Tenant erasure authentication window must be between 60 seconds and one day.")
if self.production_profile and self.required_approvals < 2:
raise ValueError("Production tenant erasure requires at least two approvals.")
def to_dict(self) -> dict[str, object]:
return {
"production_profile": self.production_profile,
"required_approvals": self.required_approvals,
"preview_ttl_seconds": self.preview_ttl_seconds,
"recent_authentication_seconds": self.recent_authentication_seconds,
}
def tenant_erasure_policy(session: Session) -> TenantErasurePolicy:
settings = get_system_settings(session)
raw = (settings.settings or {}).get(TENANT_ERASURE_POLICY_KEY, {})
if raw is None:
raw = {}
if not isinstance(raw, dict):
raise ValueError("Tenant erasure policy must be an object.")
allowed = {
"production_profile",
"required_approvals",
"preview_ttl_seconds",
"recent_authentication_seconds",
}
unknown = set(raw) - allowed
if unknown:
raise ValueError(
"Tenant erasure policy contains unsupported fields: "
+ ", ".join(sorted(str(item) for item in unknown))
)
production = raw.get("production_profile", True)
if type(production) is not bool:
raise ValueError("Tenant erasure production profile must be boolean.")
default_approvals = 2 if production else 1
return TenantErasurePolicy(
production_profile=production,
required_approvals=_policy_integer(
raw, "required_approvals", default_approvals
),
preview_ttl_seconds=_policy_integer(
raw, "preview_ttl_seconds", 900
),
recent_authentication_seconds=_policy_integer(
raw, "recent_authentication_seconds", 900
),
)
def _policy_integer(raw: dict[str, object], key: str, default: int) -> int:
value = raw.get(key, default)
if type(value) is not int:
raise ValueError(f"Tenant erasure {key} must be an integer.")
return value
def _canonical_digest(value: object) -> str:
encoded = json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def tenant_erasure_inventory_digest(inventory: TenantErasureInventory | dict[str, Any]) -> str:
payload = inventory.to_dict() if isinstance(inventory, TenantErasureInventory) else inventory
stable = {
"schema_version": payload.get("schema_version"),
"tenant_id": payload.get("tenant_id"),
"complete": payload.get("complete"),
"allowed": payload.get("allowed"),
"modules": payload.get("modules"),
}
return _canonical_digest(stable)
def create_tenant_erasure_operation(
session: Session,
*,
registry: object,
tenant_id: str,
idempotency_key: str,
requested_by_account_id: str,
reason: str | None,
current_time: datetime | None = None,
) -> tuple[TenantErasureOperation, bool]:
normalized_key = idempotency_key.strip()
if len(normalized_key) < 8 or len(normalized_key) > 160:
raise ValueError("Tenant erasure idempotency key is invalid.")
normalized_reason = reason.strip() if reason and reason.strip() else None
request_digest = _canonical_digest(
{
"tenant_id": tenant_id,
"idempotency_key": normalized_key,
"reason": normalized_reason,
}
)
existing = session.execute(
select(TenantErasureOperation).where(
TenantErasureOperation.tenant_id == tenant_id,
TenantErasureOperation.idempotency_key == normalized_key,
)
).scalar_one_or_none()
if existing is not None:
if existing.request_digest != request_digest:
raise TenantErasureConflict(
"Tenant erasure idempotency key was already used for another request."
)
return existing, True
now = _aware_utc(current_time or utc_now())
policy = tenant_erasure_policy(session)
inventory = collect_tenant_erasure_inventory(
registry,
session,
tenant_id,
observed_at=now,
)
preview = inventory.to_dict()
operation = TenantErasureOperation(
tenant_id=tenant_id,
state="awaiting_approval" if inventory.allowed else "blocked",
idempotency_key=normalized_key,
request_digest=request_digest,
preview_digest=tenant_erasure_inventory_digest(inventory),
preview=preview,
previewed_at=now,
preview_expires_at=now + timedelta(seconds=policy.preview_ttl_seconds),
policy=policy.to_dict(),
approvals=[],
steps=_planned_steps(inventory),
requested_by_account_id=requested_by_account_id,
reason=normalized_reason,
destructive_started=False,
revision=1,
)
session.add(operation)
session.flush()
return operation, False
def _planned_steps(inventory: TenantErasureInventory) -> list[dict[str, object]]:
planned: list[dict[str, object]] = []
for module in inventory.modules:
pending = {step.step_id: step for step in module.steps}
resolved: set[str] = set()
while pending:
step = min(
(
candidate
for candidate in pending.values()
if set(candidate.depends_on).issubset(resolved)
),
key=lambda candidate: candidate.step_id,
)
planned.append(
{
"module_id": module.module_id,
**step.to_dict(),
"state": "planned",
"attempts": 0,
"result": None,
}
)
resolved.add(step.step_id)
pending.pop(step.step_id)
return planned
def approve_tenant_erasure_operation(
operation: TenantErasureOperation,
*,
account_id: str,
confirmation: str,
tenant_slug: str,
current_time: datetime | None = None,
) -> bool:
now = _aware_utc(current_time or utc_now())
_assert_preview_current(operation, now)
if operation.state == "blocked":
raise TenantErasureConflict("Blocked tenant erasure cannot be approved.")
if operation.state in TERMINAL_ERASURE_STATES:
raise TenantErasureConflict(
f"Tenant erasure operation is already {operation.state}."
)
if confirmation != tenant_slug:
raise TenantErasureConflict("Typed tenant confirmation does not match the tenant slug.")
approvals = list(operation.approvals or [])
if any(item.get("account_id") == account_id for item in approvals):
return True
approvals.append({"account_id": account_id, "approved_at": now.isoformat()})
operation.approvals = approvals
required = int((operation.policy or {}).get("required_approvals", 2))
operation.state = "ready" if len(approvals) >= required else "awaiting_approval"
operation.revision += 1
return False
def cancel_tenant_erasure_operation(
operation: TenantErasureOperation,
*,
current_time: datetime | None = None,
) -> None:
del current_time
if operation.destructive_started:
raise TenantErasureConflict(
"Tenant erasure cannot be cancelled after destructive work started."
)
if operation.state == "completed":
raise TenantErasureConflict("Completed tenant erasure cannot be cancelled.")
operation.state = "cancelled"
operation.last_error = None
operation.revision += 1
def tenant_erasure_recently_authenticated(
auth_session: object | None,
policy: TenantErasurePolicy | dict[str, Any],
*,
current_time: datetime | None = None,
) -> bool:
created_at = getattr(auth_session, "created_at", None)
if not isinstance(created_at, datetime):
return False
created_at = _aware_utc(created_at)
now = _aware_utc(current_time or utc_now())
seconds = (
policy.recent_authentication_seconds
if isinstance(policy, TenantErasurePolicy)
else int(policy.get("recent_authentication_seconds", 900))
)
elapsed = now - created_at
return timedelta(0) <= elapsed <= timedelta(seconds=seconds)
def assert_tenant_erasure_executable(
operation: TenantErasureOperation,
*,
confirmation: str,
tenant_slug: str,
current_time: datetime | None = None,
) -> None:
now = _aware_utc(current_time or utc_now())
_assert_preview_current(operation, now)
if confirmation != tenant_slug:
raise TenantErasureConflict("Typed tenant confirmation does not match the tenant slug.")
if operation.state not in {"ready", "running", "reconciliation_required"}:
raise TenantErasureConflict(
f"Tenant erasure operation is not executable while {operation.state}."
)
required = int((operation.policy or {}).get("required_approvals", 2))
approvers = {
str(item.get("account_id"))
for item in operation.approvals or []
if item.get("account_id")
}
if len(approvers) < required:
raise TenantErasureConflict("Tenant erasure does not have enough distinct approvals.")
def verify_tenant_erasure_preview(
session: Session,
*,
registry: object,
operation: TenantErasureOperation,
current_time: datetime | None = None,
) -> bool:
now = _aware_utc(current_time or utc_now())
_assert_preview_current(operation, now)
inventory = collect_tenant_erasure_inventory(
registry,
session,
operation.tenant_id,
observed_at=now,
)
return tenant_erasure_inventory_digest(inventory) == operation.preview_digest
def run_tenant_erasure_steps(
session: Session,
*,
registry: object,
operation: TenantErasureOperation,
current_time: datetime | None = None,
) -> bool:
"""Run or reconcile provider steps, committing a checkpoint before each effect."""
now = _aware_utc(current_time or utc_now())
providers = tenant_erasure_providers(registry)
operation.state = "running"
operation.started_at = operation.started_at or now
operation.last_error = None
operation.revision += 1
session.add(operation)
session.commit()
for index, original in enumerate(list(operation.steps or [])):
if original.get("state") == "completed":
continue
if not _dependencies_completed(operation.steps, original):
operation.state = "reconciliation_required"
operation.last_error = "A tenant erasure step dependency is incomplete."
operation.revision += 1
session.add(operation)
session.commit()
return False
module_id = str(original.get("module_id") or "")
step_id = str(original.get("step_id") or "")
provider = providers.get(module_id)
if provider is None:
operation.state = "reconciliation_required"
operation.last_error = f"Tenant erasure provider {module_id} is unavailable."
operation.revision += 1
session.add(operation)
session.commit()
return False
step = dict(original)
previous_state = str(step.get("state") or "planned")
step["state"] = "running"
step["started_at"] = utc_now().isoformat()
step["attempts"] = int(step.get("attempts") or 0) + 1
steps = list(operation.steps or [])
steps[index] = step
operation.steps = steps
if bool(step.get("destructive")):
operation.destructive_started = True
operation.revision += 1
session.add(operation)
session.commit()
execution_key = f"{operation.id}:{module_id}:{step_id}"
try:
if previous_state in {"pending", "outcome_unknown", "running"}:
result = provider.reconcile_tenant_erasure_step(
session,
operation.tenant_id,
step_id,
execution_key,
)
else:
result = provider.execute_tenant_erasure_step(
session,
operation.tenant_id,
step_id,
execution_key,
)
if not isinstance(result, TenantErasureStepResult):
raise TypeError("Tenant erasure provider returned an invalid result.")
except Exception as exc:
session.rollback()
operation = session.get(TenantErasureOperation, operation.id)
if operation is None:
raise RuntimeError("Tenant erasure checkpoint disappeared.") from exc
steps = list(operation.steps or [])
failed = dict(steps[index])
failed["state"] = "outcome_unknown"
failed["result"] = {
"state": "outcome_unknown",
"summary": f"{type(exc).__name__}: provider outcome requires reconciliation",
"receipt_ref": None,
"metrics": {},
}
steps[index] = failed
operation.steps = steps
operation.state = "reconciliation_required"
operation.last_error = str(failed["result"]["summary"])
operation.revision += 1
session.add(operation)
session.commit()
return False
steps = list(operation.steps or [])
completed = dict(steps[index])
completed["state"] = result.state
completed["result"] = result.to_dict()
steps[index] = completed
operation.steps = steps
operation.revision += 1
if result.state == "completed":
session.add(operation)
session.commit()
continue
operation.state = "reconciliation_required"
operation.last_error = result.summary
session.add(operation)
session.commit()
return False
operation.state = "running"
operation.last_error = None
operation.revision += 1
session.add(operation)
session.commit()
return True
def complete_tenant_erasure_operation(
operation: TenantErasureOperation,
*,
current_time: datetime | None = None,
) -> None:
operation.state = "completed"
operation.completed_at = _aware_utc(current_time or utc_now())
operation.reason = None
operation.last_error = None
operation.revision += 1
def _dependencies_completed(
steps: list[dict[str, Any]],
candidate: dict[str, Any],
) -> bool:
module_id = candidate.get("module_id")
dependencies = set(candidate.get("depends_on") or [])
completed = {
step.get("step_id")
for step in steps
if step.get("module_id") == module_id and step.get("state") == "completed"
}
return dependencies.issubset(completed)
def _assert_preview_current(
operation: TenantErasureOperation,
current_time: datetime,
) -> None:
if _aware_utc(operation.preview_expires_at) < current_time:
raise TenantErasureConflict(
"Tenant erasure preview expired; create a new operation."
)
def _aware_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
__all__ = [
"TENANT_ERASURE_POLICY_KEY",
"TenantErasureConflict",
"TenantErasurePolicy",
"approve_tenant_erasure_operation",
"assert_tenant_erasure_executable",
"cancel_tenant_erasure_operation",
"complete_tenant_erasure_operation",
"create_tenant_erasure_operation",
"run_tenant_erasure_steps",
"tenant_erasure_inventory_digest",
"tenant_erasure_policy",
"tenant_erasure_recently_authenticated",
"verify_tenant_erasure_preview",
]
@@ -0,0 +1,20 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'tenancy.reference.admin-fields': {'consequence_classes': {'create': 'Erstellt eine neue '
'Mandantgrenze und stellt '
'seinen geschützten '
'ursprünglichen Eigentümer '
'bereit.',
'suspend': 'Blockiert die normale '
'Nutzung von Mandantn, '
'während Daten und '
'Prüfungsnachweise '
'beibehalten werden.',
'update': 'Ändert Mandanten-lokale '
'Identität, Locale oder '
'Governance-Konfiguration.'}}}
+348 -14
View File
@@ -1,14 +1,41 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_tenancy.backend.german_structured_documentation import (
GERMAN_STRUCTURED_TRANSLATIONS,
)
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
CAPABILITY_TENANCY_TENANT_RESOLVER,
)
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, FrontendModule, ModuleContext, ModuleManifest
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_tenancy.backend.db.models import TenantErasureOperation
from govoplan_tenancy.backend.dsar_provider import (
TENANCY_DSAR_CAPABILITY,
TenancyDsarProvider,
)
def _tenant_resolver(context: ModuleContext):
@@ -29,20 +56,74 @@ def _route_factory(context: ModuleContext):
return aggregate
def _dsar_provider(_context: ModuleContext) -> TenancyDsarProvider:
return TenancyDsarProvider()
manifest = ModuleManifest(
id="tenancy",
name="Tenancy",
version="0.1.17",
version="0.1.22",
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
),
provides_interfaces=(
ModuleInterfaceProvider(name=TENANCY_DSAR_CAPABILITY, version="0.1.0"),
),
route_factory=_route_factory,
capability_factories={
CAPABILITY_TENANCY_TENANT_RESOLVER: _tenant_resolver,
TENANCY_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
TENANCY_DSAR_CAPABILITY: CapabilityDocumentation(
label="Tenancy data-subject request provider",
summary=(
"Exports bounded tenant-erasure actor and approval evidence and "
"explains its non-executable governance retention."
),
contract_version="0.1.0",
),
},
migration_spec=MigrationSpec(
module_id="tenancy",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
TenantErasureOperation,
label="Tenancy erasure-operation evidence",
),
retirement_notes=(
"Destructive retirement removes tenant-erasure approvals and "
"checkpoint evidence only after the installer captures a database snapshot."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
TenantErasureOperation,
label="tenant-erasure operations",
),
),
documentation=(
DocumentationTopic(
id="tenancy.reference.navigation-layout",
title="Arrange tenant navigation",
summary="Set a tenant default with the shared module and separator editor.",
body=(
"Tenant Settings uses the same drag-and-drop layout editor as system, personal and View configuration. Add or remove available navigation modules, insert optionally labelled separators, and drag either kind of entry. Keyboard handles use Space to pick up, arrows to move, Enter to drop and Escape to cancel; arrow buttons offer the same moves. Removing a module here only hides its navigation entry. System locks remain visible and cannot be removed; tenant locks prevent personal removal while permitting reordering. User preferences take precedence over the tenant layout. Opening does not dirty the page; Save persists the draft and Use inherited layout removes the tenant override. Collapsed rails show the group boundaries as horizontal lines. Entitlements, Views and permissions are not changed."
" Without configuration, available modules already form the standard groups Work; Services and cases; Records and documents; Communication; Meetings and decisions; Data and assurance; People and responsibility. Expanded rails show the headings without divider lines. The editor indicates inheritance, custom grouping or an explicitly flat layout, which is preserved until deliberately changed. Use inherited layout and Save removes only this tenant's override and restores its current system baseline; personal and system settings are not rewritten. Personal preferences take precedence over tenant, system and standard defaults, except that locked entries cannot be hidden."
),
documentation_types=("user", "admin"), audience=("tenant_admin", "system_admin", "user"),
related_modules=("admin", "access", "views"), metadata={"kind": "reference"},
translations={"de": {"title": "Mandantennavigation anordnen", "summary": "Mandantenstandard mit dem gemeinsamen Editor für Module und Trennlinien festlegen.",
"body": (
"Die Mandanteneinstellungen verwenden denselben Drag-and-drop-Editor wie System-, persönliche und View-Konfiguration. Ergänzen oder entfernen Sie verfügbare Navigationsmodule, fügen Sie optional benannte Trennlinien ein und ziehen Sie beide Eintragsarten. Am Tastaturgriff nimmt die Leertaste auf, Pfeile verschieben, Eingabe legt ab und Escape bricht ab; Pfeilschaltflächen bieten dieselben Schritte. Das Entfernen blendet nur den Navigationseintrag aus. Systemsperren bleiben sichtbar und können nicht entfernt werden. Mandantensperren verhindern persönliches Entfernen, erlauben aber Umordnen. Benutzerpräferenzen haben Vorrang vor der Mandantenanordnung. Das Öffnen erzeugt keine Änderung. Speichern übernimmt den Entwurf; Geerbte Anordnung verwenden entfernt die Mandantenausnahme. Eingeklappte Leisten zeigen Gruppengrenzen als horizontale Linien. Modulfreigaben, Views und Berechtigungen ändern sich nicht."
" Ohne Konfiguration bilden verfügbare Module bereits die Standardgruppen Arbeit; Leistungen und Vorgänge; Akten und Dokumente; Kommunikation; Termine und Entscheidungen; Daten und Qualitätssicherung; Personen und Verantwortung. Ausgeklappte Leisten zeigen diese Überschriften ohne Trennlinien. Der Editor kennzeichnet Vererbung, eigene Gruppierung oder eine ausdrücklich ungegliederte Anordnung, die bis zur bewussten Änderung erhalten bleibt. Geerbte Anordnung verwenden und Speichern entfernt nur die Anpassung dieses Mandanten und stellt die aktuelle Systemvorgabe wieder her; persönliche und Systemeinstellungen werden nicht umgeschrieben. Persönliche Vorgaben haben Vorrang vor Mandant, System und Standard; gesperrte Einträge können dennoch nicht ausgeblendet werden."
)}},
),
DocumentationTopic(
id="tenancy.current-context",
title="Work in the correct tenant context",
@@ -51,6 +132,20 @@ manifest = ModuleManifest(
documentation_types=("user",),
audience=("user", "tenant_admin"),
related_modules=("access",),
translations={
"de": {
"title": "Im richtigen Mandantenkontext arbeiten",
"summary": (
"Der aktive Mandant bestimmt, welche mandantenbezogenen Daten, Rollen, Einstellungen und Modulkonfigurationen für eine "
"Anfrage sichtbar sind."
),
"body": (
"Konten mit Zugriff auf mehrere Mandanten können den Kontext über die Mandantenauswahl der Plattform wechseln. Der "
"Wechsel ändert den aktiven Geltungsbereich; er kopiert keine Daten und gewährt keine neue Befugnis. Prüfen Sie den "
"ausgewählten Mandanten immer, bevor Sie mandanteneigene Datensätze anlegen oder ändern."
),
}
},
metadata={
"kind": "reference",
"help_contexts": ["tenancy.current-context", "tenancy.selector"],
@@ -60,37 +155,263 @@ manifest = ModuleManifest(
id="tenancy.lifecycle-and-settings",
title="Administer tenant lifecycle and settings",
summary="Tenancy adds explicit tenant creation, activation, context resolution, and tenant-owned settings over Core's shared scope storage.",
body="A tenant is a concrete administrative and data boundary. Tenant lifecycle changes must preserve ownership and recovery guarantees for module-owned records. New tenants default to the German reference language unless the administrator selects another enabled system language; existing tenant and user preferences remain unchanged. Tenancy contributes system tenant management and tenant settings to the shared administration workspace; without this module, the Core and Access baseline can operate in single-scope compatibility mode. Core-reserved module entitlement settings are managed only through the Admin module's tenant-module policy endpoints and are preserved when generic tenant settings are replaced.",
documentation_types=("admin",),
body="Documentation books sit immediately beside the visible heading or contextual label for "
"Tenants and tenant settings, not among operational action buttons. Field help remains beside "
"its label. "
"A tenant is a concrete administrative and data boundary. Tenant lifecycle changes must preserve ownership and recovery guarantees for module-owned records. Retirement and suspension retain data. Destructive erasure is a separate, provider-neutral workflow with a digest-bound preview, recent interactive authentication, exact slug confirmation, distinct approvals, resumable checkpoints, and final reconciliation before the Core scope is removed. Provider absence, timeout, stale evidence, tenant data without an erasure contribution, legal holds, retention requirements, external cleanup, backup expiry, and unknown outcomes remain visible and fail closed. Execution suspends access before the first provider step; cancellation is permitted only before destructive work begins. New tenants default to the German reference language unless the administrator selects another enabled system language; existing tenant and user preferences remain unchanged. Tenant administrators can inherit or override the system side-rail order and visibility and can lock entries visible for users; system locks remain effective. Personal navigation preferences still take precedence except that they cannot hide locked entries. Tenant appearance likewise inherits the system palette until explicitly selected; an unlocked tenant default permits a personal palette, while a policy-authorized tenant lock suppresses it and a system lock always wins. Resetting the tenant palette restores inheritance rather than copying the current system value. When the system permits advanced personal color overrides, a policy-authorized tenant administrator may inherit, allow, or block them; the tenant cannot enable a system-denied policy, and palette locks still suppress the editor. Advanced documents cover both light and dark modes and are validated atomically by Core. Navigation and appearance changes never grant module entitlement, View visibility, or permissions. Tenancy contributes system tenant management and tenant settings to the shared administration workspace; without this module, the Core and Access baseline can operate in single-scope compatibility mode. Core-reserved module entitlement settings are managed only through the Admin module's tenant-module policy endpoints and are preserved when generic tenant settings are replaced. Opening lists and creation or edit dialogs does not save changes. If an authorized section reports a load failure, reload after deployment and report the frontend diagnostic; do not broaden permissions or recreate records to work around a render failure.",
documentation_types=("admin", "user"),
audience=("system_admin", "tenant_admin", "operator"),
related_modules=("access", "admin", "audit"),
links=(
DocumentationLink(label="Tenant administration", href="/admin", kind="runtime"),
DocumentationLink(label="Tenant registry API", href="/api/v1/admin/tenants", kind="api"),
DocumentationLink(label="Tenant settings API", href="/api/v1/admin/tenant/settings", kind="api"),
conditions=(
DocumentationCondition(
required_modules=("tenancy", "access"),
any_scopes=(
"access:tenant:read",
"access:tenant:update",
"access:setting:read",
"access:setting:write",
"system:tenants:erase",
),
),
),
links=(
DocumentationLink(
label="Tenant administration", href="/admin", kind="runtime"
),
DocumentationLink(
label="Tenant registry API",
href="/api/v1/admin/tenants",
kind="api",
),
DocumentationLink(
label="Tenant settings API",
href="/api/v1/admin/tenant/settings",
kind="api",
),
DocumentationLink(
label="Tenant erasure preview API",
href="/api/v1/admin/tenants/{tenant_id}/erasure-operations",
kind="api",
),
),
translations={
"de": {
"title": "Mandantenlebenszyklus und -einstellungen administrieren",
"summary": (
"Tenancy ergänzt ausdrückliche Mandantenanlage, Aktivierung, Kontextauflösung und mandanteneigene Einstellungen über "
"Cores gemeinsamen Bereichsspeicher."
),
"body": (
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
"Kontextbezeichnung für Mandanten und Mandanteneinstellungen, nicht zwischen ausführbaren "
"Aktionsschaltflächen. Feldhilfe bleibt neben der Feldbezeichnung. "
"Das Öffnen von Listen sowie Anlage- oder Bearbeitungsdialogen speichert keine Änderungen. Meldet ein berechtigter Bereich einen Ladefehler, laden Sie nach der Bereitstellung neu und melden Sie die Oberflächendiagnose; erweitern Sie keine Berechtigungen und legen Sie keine Datensätze erneut an, um einen Darstellungsfehler zu umgehen. "
"Ein Mandant ist eine konkrete Administrations- und Datengrenze. Änderungen am Mandantenlebenszyklus müssen Eigentums- "
"und Wiederherstellungsgarantien für modulbezogene Datensätze bewahren. Ruhestand und Sperrung bewahren Daten. Die "
"destruktive Löschung ist ein eigener, anbieterneutraler Ablauf mit digestgebundener Vorschau, aktueller interaktiver "
"Authentifizierung, exakter Slug-Bestätigung, getrennten Freigaben, fortsetzbaren Prüfpunkten und abschließendem Abgleich, "
"bevor Core den Mandantenbereich entfernt. Fehlende oder nicht erreichbare Anbieter, veraltete Nachweise, Mandantendaten "
"ohne Löschbeitrag, rechtliche Sperren, Aufbewahrung, externe Bereinigung, Ablauf von Sicherungen und unbekannte Ergebnisse "
"bleiben sichtbar und blockieren. Die Ausführung sperrt den Zugriff vor dem ersten Anbieterschritt; ein Abbruch ist nur vor "
"destruktiver Arbeit möglich. Neue Mandanten verwenden standardmäßig die "
"deutsche Referenzsprache, sofern keine andere aktivierte Systemsprache gewählt wird; bestehende Mandanten- und "
"Benutzerpräferenzen bleiben unverändert. Mandantenadministrierende können systemweite Reihenfolge und Sichtbarkeit der "
"Seitenleiste erben oder überschreiben und Einträge für Benutzende sichtbar sperren; Systemsperren bleiben wirksam. "
"Persönliche Navigationspräferenzen behalten Vorrang, können gesperrte Einträge aber nicht ausblenden. Das Erscheinungsbild "
"erbt ebenfalls die Systempalette, bis es ausdrücklich gewählt wird. Ein ungesperrter Mandantenstandard erlaubt eine "
"persönliche Palette; eine richtlinienautorisierte Mandantensperre unterdrückt sie, und eine Systemsperre hat immer Vorrang. "
"Zurücksetzen stellt Vererbung wieder her, statt den aktuellen Systemwert zu kopieren. Erlaubt das System erweiterte "
"persönliche Farbanpassungen, darf eine richtlinienautorisierte Mandantenadministration sie erben, erlauben oder blockieren; "
"eine systemweite Ablehnung kann nicht gelockert werden und Palettensperren unterdrücken den Editor weiterhin. Erweiterte "
"Dokumente umfassen hellen und dunklen Modus und werden von Core atomar validiert. Navigation und Erscheinungsbild gewähren "
"niemals Modulberechtigung, View-Sichtbarkeit oder Zugriffsrechte. Tenancy trägt systemweite Mandantenverwaltung und "
"Mandanteneinstellungen zum gemeinsamen Admin-Arbeitsbereich bei; ohne das Modul können Core und Access in einem "
"Einzelbereichskompatibilitätsmodus arbeiten. Für Core reservierte Modulberechtigungseinstellungen werden ausschließlich "
"über Admins Mandanten-Modulrichtlinienendpunkte verwaltet und beim Ersetzen allgemeiner Mandanteneinstellungen bewahrt."
),
}
},
metadata={
"kind": "reference",
"kind": "workflow",
"help_contexts": [
"tenancy.admin.system-tenants",
"tenancy.admin.tenant-settings",
"tenancy.admin.lifecycle",
"tenancy.admin.blocked",
"tenancy.admin.tenant-erasure",
],
},
),
DocumentationTopic(
id="tenancy.workflow.tenant-erasure",
title="Preview, approve, and reconcile tenant erasure",
summary=(
"System operators erase a tenant only through fresh provider evidence, "
"typed confirmation, policy-defined approvals, and resumable checkpoints."
),
body=(
"Create an erasure operation with a unique idempotency key and review every "
"module resource, disposition, warning, blocker, irreversible step, external "
"cleanup, key-destruction task, and backup-expiry obligation. The system setting "
"tenant_erasure_policy selects production mode, one to ten required distinct "
"approvals, preview lifetime, and recent-authentication window. Production mode "
"requires at least two approvals; the safe default is two approvals and fifteen "
"minutes for preview and authentication freshness. Each approver types the exact "
"tenant slug from a recently authenticated interactive session. Execution requires "
"the dedicated system:tenants:erase permission, repeats the confirmation, verifies "
"that the preview digest still matches, suspends the tenant, and checkpoints before "
"every provider effect. Timeouts and pending or unknown outcomes stop in "
"reconciliation_required; the reconcile action uses the same provider idempotency "
"key. Cancellation is rejected after destructive work starts. Completion removes "
"the Core scope only after a new inventory and all delete vetoes are clear, then "
"retains bounded operation and audit evidence without the typed confirmation, "
"request reason, credentials, or erased tenant content. The Tenancy DSAR "
"provider exports a subject's requester/approver role and timestamps and "
"explains why this bounded authorization and recovery evidence is retained."
),
documentation_types=("admin",),
audience=("system_admin", "operator", "security_reviewer"),
related_modules=("access", "audit", "policy"),
conditions=(
DocumentationCondition(
required_modules=("tenancy", "access"),
any_scopes=("system:tenants:erase",),
),
),
links=(
DocumentationLink(
label="Tenant erasure policy API",
href="/api/v1/admin/tenant-erasure-policy",
kind="api",
),
DocumentationLink(
label="Create erasure preview",
href="/api/v1/admin/tenants/{tenant_id}/erasure-operations",
kind="api",
),
),
translations={
"de": {
"title": "Mandantenlöschung vorschauen, freigeben und abgleichen",
"summary": (
"Systembetriebspersonen löschen einen Mandanten nur mit aktuellen "
"Anbieternachweisen, Texteingabebestätigung, richtlinienbestimmten "
"Freigaben und fortsetzbaren Prüfpunkten."
),
"body": (
"Erstellen Sie einen Löschvorgang mit eindeutigem Idempotenzschlüssel und prüfen Sie jede Modulressource, Behandlung, "
"Warnung, Sperre, jeden irreversiblen Schritt sowie Aufgaben für externe Bereinigung, Schlüsselvernichtung und den Ablauf "
"von Sicherungen. Die Systemeinstellung tenant_erasure_policy bestimmt Produktionsmodus, eine bis zehn getrennte Freigaben, "
"Gültigkeit der Vorschau und Zeitfenster der aktuellen Authentifizierung. Im Produktionsmodus sind mindestens zwei Freigaben "
"erforderlich; der sichere Standard sind zwei Freigaben und jeweils fünfzehn Minuten. Jede freigebende Person gibt in einer "
"aktuell authentifizierten interaktiven Sitzung den exakten Mandanten-Slug ein. Die Ausführung benötigt das eigene Recht "
"system:tenants:erase, wiederholt die Bestätigung, prüft den Vorschau-Digest, sperrt den Mandanten und schreibt vor jeder "
"Anbieterwirkung einen Prüfpunkt. Zeitüberschreitungen sowie ausstehende oder unbekannte Ergebnisse stoppen im Zustand "
"reconciliation_required; der Abgleich nutzt denselben Anbieter-Idempotenzschlüssel. Nach Beginn destruktiver Arbeit ist ein "
"Abbruch ausgeschlossen. Core entfernt den Mandantenbereich erst, wenn eine neue Inventur und alle Löschvetos frei sind. "
"Danach bleiben begrenzte Vorgangs- und Auditnachweise ohne Texteingabebestätigung, Antragsgrund, Anmeldedaten oder gelöschte "
"Mandanteninhalte erhalten. 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."
),
}
},
metadata={
"kind": "workflow",
"help_contexts": ["tenancy.admin.tenant-erasure"],
},
),
DocumentationTopic(
id="tenancy.workflow.data-subject-request",
title="Review Tenancy evidence in a data-subject request",
summary=(
"Privacy officers can export a subject's bounded role in tenant-erasure "
"operations while preserving required governance evidence."
),
body=(
"The Tenancy DSAR provider matches the authenticated account identifier "
"and returns a bounded set of matching tenant-erasure operations, failing "
"closed when the tenant scan or subject result limit is exceeded. "
"It reports only the operation state, the subject's requester or approver "
"role and timestamps, and whether destructive work or completion occurred. "
"An unfinished request reason is visible only to its requester; typed "
"confirmation, credentials, provider payloads, erased tenant content, and a "
"completed request reason are never exported. This authorization and recovery "
"record is immutable governance evidence, so the erasure plan marks it for "
"retention and execution returns a blocking explanation instead of deleting it. "
"Use Audit and the owning domain providers to complete the wider request."
),
documentation_types=("admin",),
audience=("privacy_officer", "system_admin", "security_reviewer"),
related_modules=("access", "audit"),
translations={
"de": {
"title": "Tenancy-Nachweise in einer Betroffenenanfrage prüfen",
"summary": (
"Datenschutzverantwortliche können die begrenzte Rolle einer betroffenen "
"Person in Mandantenlöschvorgängen exportieren und erforderliche "
"Governance-Nachweise bewahren."
),
"body": (
"Der Tenancy-DSAR-Anbieter gleicht die authentifizierte Konto-ID ab und "
"liefert eine begrenzte Menge passender Mandantenlöschvorgänge; beim "
"Überschreiten der Mandanten- oder Betroffenenbegrenzung bricht er sicher ab. "
"Ausgegeben werden nur Vorgangszustand, Rolle und Zeitstempel der "
"betroffenen antragstellenden oder freigebenden Person sowie Angaben "
"dazu, ob destruktive Arbeit oder der Abschluss erfolgt ist. Ein noch "
"offener Antragsgrund ist ausschließlich für die antragstellende Person "
"sichtbar; Texteingabebestätigung, Anmeldedaten, Anbieterinhalte, gelöschte "
"Mandantendaten und der Grund eines abgeschlossenen Antrags werden nie "
"exportiert. Dieser Autorisierungs- und Wiederherstellungsnachweis ist ein "
"unveränderlicher Governance-Beleg. Der Löschplan kennzeichnet ihn daher "
"zur Aufbewahrung und die Ausführung liefert statt einer Löschung eine "
"blockierende Begründung. Audit und die zuständigen Fachmodule vervollständigen "
"die übergreifende Anfrage."
),
}
},
metadata={
"kind": "workflow",
"help_contexts": ["tenancy.admin.data-subject-request"],
},
),
DocumentationTopic(
id="tenancy.reference.admin-fields",
title="Tenant administration fields and consequences",
summary="Tenant identity, ownership, locale, governance overrides, and lifecycle state have different mutation and recovery consequences.",
body="A tenant slug is immutable after creation and identifies the administrative boundary. The initial owner receives the protected tenant-owner role. German is the reference and new-tenant default; locale and enabled languages are bounded by system language packages and may be changed explicitly. Governance overrides may narrow a system allowance but cannot loosen a system denial. Suspension keeps tenant-owned data and audit evidence while preventing normal use; an operator must switch away from the active tenant before suspending it.",
body="A tenant slug is immutable after creation and identifies the administrative boundary. The initial owner receives the protected tenant-owner role. German is the reference and new-tenant default; locale and enabled languages are bounded by system language packages and may be changed explicitly. Tenant navigation and appearance inherit their system layers until explicitly saved. Palette choices use validated Core presets only. A tenant appearance lock requires policy-write authority, suppresses personal palette choices, and cannot relax a system lock. Governance overrides may narrow a system allowance but cannot loosen a system denial. Suspension keeps tenant-owned data and audit evidence while preventing normal use; an operator must switch away from the active tenant before suspending it. Destructive erasure uses the exact immutable slug as its confirmation phrase and cannot reuse the suspension permission.",
documentation_types=("admin",),
audience=("system_admin", "tenant_admin", "operator"),
related_modules=("access", "admin", "audit"),
links=(
DocumentationLink(label="Tenant administration", href="/admin", kind="runtime"),
DocumentationLink(label="Tenant registry API", href="/api/v1/admin/tenants", kind="api"),
DocumentationLink(
label="Tenant administration", href="/admin", kind="runtime"
),
DocumentationLink(
label="Tenant registry API",
href="/api/v1/admin/tenants",
kind="api",
),
),
translations={
"de": {
"title": "Felder und Folgen der Mandantenadministration",
"summary": (
"Mandantenidentität, Eigentum, Sprache, Governance-Überschreibungen und Lebenszykluszustand haben unterschiedliche "
"Änderungs- und Wiederherstellungsfolgen."
),
"body": (
"Der Slug eines Mandanten ist nach der Anlage unveränderlich und bezeichnet die Administrationsgrenze. Der erste Owner "
"erhält die geschützte Tenant-Owner-Rolle. Deutsch ist Referenz und Standard für neue Mandanten; Spracheinstellung und "
"aktivierte Sprachen werden durch Systemsprachpakete begrenzt und können ausdrücklich geändert werden. Navigation und "
"Erscheinungsbild erben ihre Systemebenen, bis sie gespeichert werden. Paletten verwenden nur validierte Core-Vorgaben. "
"Eine Mandantensperre des Erscheinungsbilds verlangt Richtlinienschreibberechtigung, unterdrückt persönliche Paletten und "
"kann keine Systemsperre lockern. Governance-Überschreibungen dürfen eine Systemerlaubnis einschränken, aber eine "
"Systemablehnung nicht lockern. Eine Suspendierung bewahrt mandanteneigene Daten und Auditnachweise, verhindert jedoch die "
"normale Nutzung; die Betriebsperson muss vor der Suspendierung aus dem aktiven Mandanten wechseln. Die destruktive Löschung "
"verwendet den exakten unveränderlichen Slug als Bestätigung und kann nicht mit dem Sperrrecht ausgeführt werden."
),
}
},
metadata={
"kind": "reference",
"help_contexts": [
@@ -98,8 +419,10 @@ manifest = ModuleManifest(
"tenancy.field.initial-owner",
"tenancy.field.locale",
"tenancy.field.languages",
"tenancy.admin.tenant-settings",
"tenancy.field.governance",
"tenancy.action.suspend",
"tenancy.admin.tenant-erasure",
],
"consequence_classes": {
"create": "Creates a new tenant boundary and provisions its protected initial owner.",
@@ -135,14 +458,25 @@ manifest = ModuleManifest(
maturity="vertical_slice",
documentation_ref="docs/TENANCY_MODULE_BOUNDARY.md",
test_ref="tests/test_tenant_lifecycle.py",
known_limits=("Cross-region tenant relocation and complete major-version recovery evidence are not implemented.",),
known_limits=(
"Cross-region tenant relocation and complete major-version recovery evidence are not implemented.",
),
owned_concepts=("tenant lifecycle", "tenant context", "tenant settings"),
non_owned_concepts=("account authorization", "organization hierarchy", "module-owned tenant data"),
non_owned_concepts=(
"account authorization",
"organization hierarchy",
"module-owned tenant data",
),
recovery_docs=("docs/TENANCY_MODULE_BOUNDARY.md",),
security_docs=("docs/TENANCY_MODULE_BOUNDARY.md",),
),
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1 @@
"""Tenancy module migrations."""
@@ -0,0 +1 @@
"""Tenancy module migration revisions."""
@@ -0,0 +1,104 @@
"""Add durable tenant-erasure operations.
Revision ID: b3d8e1f4a6c2
Revises:
Create Date: 2026-08-24
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b3d8e1f4a6c2"
down_revision = None
branch_labels = ("module:tenancy",)
depends_on = None
def upgrade() -> None:
op.create_table(
"tenancy_erasure_operations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("state", sa.String(length=40), nullable=False),
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
sa.Column("request_digest", sa.String(length=64), nullable=False),
sa.Column("preview_digest", sa.String(length=64), nullable=False),
sa.Column("preview", sa.JSON(), nullable=False),
sa.Column("previewed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("preview_expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("policy", sa.JSON(), nullable=False),
sa.Column("approvals", sa.JSON(), nullable=False),
sa.Column("steps", sa.JSON(), nullable=False),
sa.Column("requested_by_account_id", sa.String(length=36), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("destructive_started", sa.Boolean(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_tenancy_erasure_operations")),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_tenancy_erasure_tenant_idempotency",
),
)
op.create_index(
op.f("ix_tenancy_erasure_operations_tenant_id"),
"tenancy_erasure_operations",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_tenancy_erasure_operations_state"),
"tenancy_erasure_operations",
["state"],
unique=False,
)
op.create_index(
op.f("ix_tenancy_erasure_operations_preview_expires_at"),
"tenancy_erasure_operations",
["preview_expires_at"],
unique=False,
)
op.create_index(
op.f("ix_tenancy_erasure_operations_completed_at"),
"tenancy_erasure_operations",
["completed_at"],
unique=False,
)
op.create_index(
"ix_tenancy_erasure_tenant_state",
"tenancy_erasure_operations",
["tenant_id", "state"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
"ix_tenancy_erasure_tenant_state",
table_name="tenancy_erasure_operations",
)
op.drop_index(
op.f("ix_tenancy_erasure_operations_completed_at"),
table_name="tenancy_erasure_operations",
)
op.drop_index(
op.f("ix_tenancy_erasure_operations_preview_expires_at"),
table_name="tenancy_erasure_operations",
)
op.drop_index(
op.f("ix_tenancy_erasure_operations_state"),
table_name="tenancy_erasure_operations",
)
op.drop_index(
op.f("ix_tenancy_erasure_operations_tenant_id"),
table_name="tenancy_erasure_operations",
)
op.drop_table("tenancy_erasure_operations")
+96
View File
@@ -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"},
),
)
== ()
)
+18 -2
View File
@@ -6,6 +6,14 @@ from govoplan_tenancy.backend.manifest import manifest
class TenancyInterfaceDocumentationContractTests(unittest.TestCase):
def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in manifest.documentation:
german = (topic.translations or {}).get("de", {})
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
self.assertTrue(
all(str(value).strip() for value in german.values()), topic.id
)
def test_tenancy_admin_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
@@ -24,10 +32,18 @@ class TenancyInterfaceDocumentationContractTests(unittest.TestCase):
self.assertIn("tenancy.lifecycle-and-settings", topics)
self.assertIn("tenancy.reference.admin-fields", topics)
lifecycle_contexts = set(topics["tenancy.lifecycle-and-settings"].metadata["help_contexts"])
lifecycle_contexts = set(
topics["tenancy.lifecycle-and-settings"].metadata["help_contexts"]
)
self.assertIn("tenancy.admin.system-tenants", lifecycle_contexts)
self.assertIn("tenancy.admin.tenant-settings", lifecycle_contexts)
self.assertIn("tenancy.action.suspend", topics["tenancy.reference.admin-fields"].metadata["help_contexts"])
self.assertEqual(
"workflow", topics["tenancy.lifecycle-and-settings"].metadata["kind"]
)
self.assertIn(
"tenancy.action.suspend",
topics["tenancy.reference.admin-fields"].metadata["help_contexts"],
)
if __name__ == "__main__":
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import importlib
import tempfile
from pathlib import Path
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_tenancy.backend.manifest import get_manifest
def test_tenant_erasure_migration_is_declared() -> None:
migration = importlib.import_module(
"govoplan_tenancy.backend.migrations.versions."
"b3d8e1f4a6c2_v021_tenant_erasure_operations"
)
assert migration.revision == "b3d8e1f4a6c2"
assert migration.down_revision is None
assert migration.branch_labels == ("module:tenancy",)
def test_tenancy_migration_creates_erasure_operation_table_and_head() -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-tenancy-migration-") as directory:
url = f"sqlite:///{Path(directory) / 'tenancy.db'}"
migrate_database(
database_url=url,
enabled_modules=("tenancy",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
with engine.connect() as connection:
assert "b3d8e1f4a6c2" in set(
MigrationContext.configure(connection).get_current_heads()
)
assert {
name
for name in inspect(connection).get_table_names()
if name.startswith("tenancy_")
} == {"tenancy_erasure_operations"}
finally:
engine.dispose()
+293
View File
@@ -0,0 +1,293 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.tenant_erasure import (
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
TenantErasurePreview,
TenantErasureResource,
TenantErasureStep,
TenantErasureStepResult,
)
from govoplan_core.db.base import Base
from govoplan_tenancy.backend.db.models import TenantErasureOperation
from govoplan_tenancy.backend.erasure import (
TenantErasureConflict,
TenantErasurePolicy,
approve_tenant_erasure_operation,
assert_tenant_erasure_executable,
cancel_tenant_erasure_operation,
create_tenant_erasure_operation,
run_tenant_erasure_steps,
tenant_erasure_recently_authenticated,
)
class _Provider:
module_id = "files"
def __init__(self) -> None:
self.fail = False
self.calls: list[str] = []
def preview_tenant_erasure(self, session, tenant_id: str) -> TenantErasurePreview:
del session, tenant_id
return TenantErasurePreview(
module_id="files",
complete=True,
resources=(
TenantErasureResource(
resource_type="files",
count=1,
disposition="erase",
summary="One file is in scope.",
),
),
steps=(
TenantErasureStep(
step_id="erase-files",
kind="erase",
summary="Erase files.",
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, idempotency_key
self.calls.append(f"execute:{step_id}")
if self.fail:
raise TimeoutError("provider timed out")
return TenantErasureStepResult(
state="completed",
summary="Files erased.",
metrics={"deleted": 1},
)
def reconcile_tenant_erasure_step(
self, session, tenant_id: str, step_id: str, idempotency_key: str
) -> TenantErasureStepResult:
del session, tenant_id, idempotency_key
self.calls.append(f"reconcile:{step_id}")
return TenantErasureStepResult(
state="completed",
summary="File erasure reconciled.",
metrics={"deleted": 1},
)
class _Registry:
def __init__(self, provider: _Provider):
self.provider = provider
def manifests(self):
return (SimpleNamespace(id="files"),)
def capability_names(self):
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):
return {}
@pytest.fixture
def session() -> Session:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine, expire_on_commit=False) as current:
yield current
def _operation(
session: Session,
provider: _Provider,
*,
current_time: datetime,
) -> TenantErasureOperation:
with patch(
"govoplan_tenancy.backend.erasure.tenant_erasure_policy",
return_value=TenantErasurePolicy(
production_profile=True,
required_approvals=2,
preview_ttl_seconds=900,
recent_authentication_seconds=900,
),
):
operation, replayed = create_tenant_erasure_operation(
session,
registry=_Registry(provider),
tenant_id="tenant-1",
idempotency_key="request-1234",
requested_by_account_id="account-1",
reason="Contract ended",
current_time=current_time,
)
session.commit()
assert not replayed
return operation
def test_operation_requires_distinct_multi_party_approval_and_typed_confirmation(
session: Session,
) -> None:
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
operation = _operation(session, _Provider(), current_time=now)
with pytest.raises(TenantErasureConflict, match="does not match"):
approve_tenant_erasure_operation(
operation,
account_id="account-1",
confirmation="wrong",
tenant_slug="target",
current_time=now,
)
assert not approve_tenant_erasure_operation(
operation,
account_id="account-1",
confirmation="target",
tenant_slug="target",
current_time=now,
)
assert operation.state == "awaiting_approval"
assert approve_tenant_erasure_operation(
operation,
account_id="account-1",
confirmation="target",
tenant_slug="target",
current_time=now,
)
assert not approve_tenant_erasure_operation(
operation,
account_id="account-2",
confirmation="target",
tenant_slug="target",
current_time=now,
)
assert operation.state == "ready"
assert_tenant_erasure_executable(
operation,
confirmation="target",
tenant_slug="target",
current_time=now,
)
def test_preview_idempotency_replays_but_rejects_changed_request(
session: Session,
) -> None:
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
provider = _Provider()
operation = _operation(session, provider, current_time=now)
with patch(
"govoplan_tenancy.backend.erasure.tenant_erasure_policy",
return_value=TenantErasurePolicy(),
):
replay, replayed = create_tenant_erasure_operation(
session,
registry=_Registry(provider),
tenant_id="tenant-1",
idempotency_key="request-1234",
requested_by_account_id="account-1",
reason="Contract ended",
current_time=now,
)
assert replayed
assert replay.id == operation.id
with pytest.raises(TenantErasureConflict, match="another request"):
create_tenant_erasure_operation(
session,
registry=_Registry(provider),
tenant_id="tenant-1",
idempotency_key="request-1234",
requested_by_account_id="account-1",
reason="Changed",
current_time=now,
)
def test_provider_timeout_requires_reconciliation_before_completion(
session: Session,
) -> None:
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
provider = _Provider()
operation = _operation(session, provider, current_time=now)
operation.state = "ready"
operation.approvals = [
{"account_id": "account-1"},
{"account_id": "account-2"},
]
session.commit()
provider.fail = True
assert not run_tenant_erasure_steps(
session,
registry=_Registry(provider),
operation=operation,
current_time=now,
)
assert operation.state == "reconciliation_required"
assert operation.steps[0]["state"] == "outcome_unknown"
assert operation.destructive_started
provider.fail = False
assert run_tenant_erasure_steps(
session,
registry=_Registry(provider),
operation=operation,
current_time=now,
)
assert provider.calls == ["execute:erase-files", "reconcile:erase-files"]
assert operation.steps[0]["state"] == "completed"
def test_cancellation_stops_at_destructive_boundary(session: Session) -> None:
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
operation = _operation(session, _Provider(), current_time=now)
cancel_tenant_erasure_operation(operation)
assert operation.state == "cancelled"
operation.state = "running"
operation.destructive_started = True
with pytest.raises(TenantErasureConflict, match="destructive work"):
cancel_tenant_erasure_operation(operation)
def test_recent_authentication_is_policy_bounded() -> None:
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
policy = TenantErasurePolicy(recent_authentication_seconds=300)
assert tenant_erasure_recently_authenticated(
SimpleNamespace(created_at=now - timedelta(minutes=4)),
policy,
current_time=now,
)
assert not tenant_erasure_recently_authenticated(
SimpleNamespace(created_at=now - timedelta(minutes=6)),
policy,
current_time=now,
)
assert not tenant_erasure_recently_authenticated(None, policy, current_time=now)
def test_production_policy_cannot_disable_multi_party_approval() -> None:
with pytest.raises(ValueError, match="at least two approvals"):
TenantErasurePolicy(production_profile=True, required_approvals=1)
non_production = TenantErasurePolicy(
production_profile=False,
required_approvals=1,
)
assert non_production.required_approvals == 1
+6 -2
View File
@@ -17,6 +17,7 @@ from govoplan_tenancy.backend.api.v1.schemas import (
TenantUpdateRequest,
)
from govoplan_core.core.module_entitlements import MODULE_ENTITLEMENTS_KEY
from govoplan_core.core.appearance import APPEARANCE_SETTINGS_KEY
from govoplan_tenancy.backend.lifecycle import (
TENANT_EVENT_CREATED,
TENANT_EVENT_DELETION_REQUESTED,
@@ -135,18 +136,20 @@ class TenantUpdateHelperTests(unittest.TestCase):
self.assertEqual("de", tenant.default_locale)
self.assertEqual({"theme": "contrast"}, tenant.settings)
def test_tenant_content_update_preserves_reserved_module_entitlements(self) -> None:
def test_tenant_content_update_preserves_reserved_governed_settings(self) -> None:
entitlement = {"schema_version": 1, "revision": 4}
appearance = {"default_palette": "forest", "palette_locked": True}
tenant = SimpleNamespace(
name="Old",
description=None,
default_locale="en",
settings={MODULE_ENTITLEMENTS_KEY: entitlement, "theme": "old"},
settings={MODULE_ENTITLEMENTS_KEY: entitlement, APPEARANCE_SETTINGS_KEY: appearance, "theme": "old"},
)
payload = TenantUpdateRequest(
settings={
"theme": "contrast",
MODULE_ENTITLEMENTS_KEY: {"revision": 999},
APPEARANCE_SETTINGS_KEY: {"default_palette": "plum", "palette_locked": False},
}
)
@@ -154,6 +157,7 @@ class TenantUpdateHelperTests(unittest.TestCase):
self.assertEqual("contrast", tenant.settings["theme"])
self.assertEqual(entitlement, tenant.settings[MODULE_ENTITLEMENTS_KEY])
self.assertEqual(appearance, tenant.settings[APPEARANCE_SETTINGS_KEY])
def test_tenant_status_update_prevents_suspending_current_tenant(self) -> None:
tenant = SimpleNamespace(id="tenant-1", is_active=True)
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/tenancy-webui",
"version": "0.1.17",
"version": "0.1.22",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -17,7 +17,7 @@
}
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.17",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
+19 -1
View File
@@ -1,8 +1,10 @@
import type {
ApiSettings,
DeltaDeletedItem,
NavigationPreferences,
PrivacyRetentionPolicy,
TenantAdminItem
TenantAdminItem,
UserUiPalette
} from "@govoplan/core-webui";
import {
apiFetch,
@@ -41,6 +43,16 @@ export type TenantSettingsItem = {
available_languages: LanguagePackage[];
system_enabled_language_codes: string[];
enabled_language_codes: string[];
navigation?: NavigationPreferences | null;
appearance_palette: UserUiPalette | null;
appearance_palette_locked: boolean;
system_appearance_palette: UserUiPalette;
system_appearance_palette_locked: boolean;
effective_appearance_palette: UserUiPalette;
effective_appearance_source: "tenant" | "system" | "tenant_lock" | "system_lock";
appearance_custom_overrides_allowed: boolean | null;
system_appearance_custom_overrides_allowed: boolean;
effective_appearance_custom_overrides_allowed: boolean;
settings: Record<string, unknown>;
};
@@ -51,6 +63,8 @@ export type TenantSettingsDeltaSections = Partial<{
TenantSettingsItem,
"available_languages" | "system_enabled_language_codes" | "enabled_language_codes"
>;
navigation: TenantSettingsItem["navigation"];
appearance: Pick<TenantSettingsItem, "appearance_palette" | "appearance_palette_locked" | "system_appearance_palette" | "system_appearance_palette_locked" | "effective_appearance_palette" | "effective_appearance_source" | "appearance_custom_overrides_allowed" | "system_appearance_custom_overrides_allowed" | "effective_appearance_custom_overrides_allowed">;
settings: TenantSettingsItem["settings"];
}>;
@@ -147,6 +161,10 @@ export function updateTenantSettings(
payload: {
default_locale: string;
enabled_language_codes?: string[] | null;
navigation?: NavigationPreferences | null;
appearance_palette?: UserUiPalette | null;
appearance_palette_locked?: boolean;
appearance_custom_overrides_allowed?: boolean | null;
}
): Promise<TenantSettingsItem> {
return apiFetch(settings, "/api/v1/admin/tenant/settings", {
@@ -1,3 +1,12 @@
import {
DescriptionList,
AppearancePalettePreview,
AppearancePaletteSelect,
NavigationPreferenceEditor,
configurableNavigationItemsForModules,
dispatchPlatformModulesChanged,
usePlatformModules
} from "@govoplan/core-webui";
import { useEffect, useState } from "react";
import {
ActionBlockerHint,
@@ -7,6 +16,7 @@ import {
Card,
DocumentationHelpLink,
FormField,
ToggleSwitch,
adminErrorMessage,
useDeltaWatermarks,
useUnsavedChanges,
@@ -34,19 +44,32 @@ const fallback: TenantSettingsItem = {
],
system_enabled_language_codes: ["de", "en"],
enabled_language_codes: ["de", "en"],
settings: {}
navigation: null,
settings: {},
appearance_palette: null,
appearance_palette_locked: false,
system_appearance_palette: "default",
system_appearance_palette_locked: false,
effective_appearance_palette: "default",
effective_appearance_source: "system",
appearance_custom_overrides_allowed: null,
system_appearance_custom_overrides_allowed: false,
effective_appearance_custom_overrides_allowed: false
};
export default function TenantSettingsPanel({
settings,
canWrite,
canWritePolicy,
onAuthRefresh
}: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
}: {settings: ApiSettings;canWrite: boolean;canWritePolicy: boolean;onAuthRefresh: () => Promise<void>;}) {
const { requestDiscard } = useUnsavedChanges();
const modules = usePlatformModules();
const navigationItems = configurableNavigationItemsForModules(modules);
const [draft, setDraft] = useState<TenantSettingsItem>(fallback);
const [savedDraft, setSavedDraft] = useState<TenantSettingsItem>(fallback);
const [loading, setLoading] = useState(true);
@@ -56,6 +79,11 @@ export default function TenantSettingsPanel({
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const defaultLocaleOptions = localeOptions(draft.default_locale, draft.enabled_language_codes);
const dirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft);
const customOverridesEffectivelyAllowed =
draft.system_appearance_custom_overrides_allowed
&& draft.appearance_custom_overrides_allowed !== false
&& !draft.system_appearance_palette_locked
&& !draft.appearance_palette_locked;
const saveDisabledReason = tenantMutationDisabledReason({
busy,
permitted: canWrite,
@@ -102,10 +130,18 @@ export default function TenantSettingsPanel({
setError("");
setSuccess("");
try {
const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale, enabled_language_codes: draft.enabled_language_codes });
const saved = await updateTenantSettings(settings, {
default_locale: draft.default_locale,
enabled_language_codes: draft.enabled_language_codes,
navigation: draft.navigation,
appearance_palette: draft.appearance_palette,
appearance_palette_locked: draft.appearance_palette_locked,
appearance_custom_overrides_allowed: draft.appearance_custom_overrides_allowed
});
setDraft(saved);
setSavedDraft(saved);
resetDeltaWatermark(DELTA_KEY);
dispatchPlatformModulesChanged();
setSuccess("i18n:govoplan-tenancy.tenant_general_settings_saved.485e7681");
await onAuthRefresh();
return true;
@@ -126,12 +162,12 @@ export default function TenantSettingsPanel({
return (
<AdminPageLayout
title="i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8"
title="i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8" titleHelp={<DocumentationHelpLink reference={TENANCY_ADMIN_DOCUMENTATION} />}
description="i18n:govoplan-tenancy.settings_for_the_active_tenant_context.ad267b86"
loading={loading}
error={error}
success={success}
actions={<><DocumentationHelpLink reference={TENANCY_ADMIN_DOCUMENTATION} /><Button onClick={() => requestDiscard(() => void load())} disabled={loading || busy} disabledReason={loading ? TENANCY_INTERFACE_I18N.loading : busy ? TENANCY_INTERFACE_I18N.busy : undefined}>i18n:govoplan-tenancy.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{busy ? "i18n:govoplan-tenancy.saving.ae7e8875" : "i18n:govoplan-tenancy.save_general_settings.5c90f8c4"}</Button></>}>
actions={<><Button onClick={() => requestDiscard(() => void load())} disabled={loading || busy} disabledReason={loading ? TENANCY_INTERFACE_I18N.loading : busy ? TENANCY_INTERFACE_I18N.busy : undefined}>i18n:govoplan-tenancy.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{busy ? "i18n:govoplan-tenancy.saving.ae7e8875" : "i18n:govoplan-tenancy.save_general_settings.5c90f8c4"}</Button></>}>
{!canWrite && <ActionBlockerHint
reason={{
@@ -167,11 +203,74 @@ export default function TenantSettingsPanel({
onChange={setEnabledLanguages}
/>
<p className="muted small-note"><span>i18n:govoplan-tenancy.tenant_languages_help</span>{" "}<span>{TENANCY_INTERFACE_I18N.defaultLanguageRequired}</span></p>
<dl className="detail-list">
<DescriptionList variant="inline">
<div><dt>i18n:govoplan-tenancy.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
<div><dt>i18n:govoplan-tenancy.slug.094da9b9</dt><dd>{draft.slug || "-"}</dd></div>
<div><dt>i18n:govoplan-tenancy.available.7c62a142</dt><dd>{draft.available_languages.map((item) => item.code.toUpperCase()).join(", ") || "-"}</dd></div>
</dl>
</DescriptionList>
</Card>
<Card title="Tenant navigation order">
<NavigationPreferenceEditor
items={navigationItems}
productAreas={modules.flatMap((module) => module.productAreas ?? [])}
value={draft.navigation}
scope="tenant"
disabled={!canWrite || busy}
onChange={(navigation) => setDraft({ ...draft, navigation })}
/>
</Card>
<Card title="i18n:govoplan-tenancy.appearance_defaults">
<FormField label="i18n:govoplan-tenancy.tenant_palette_default" help="i18n:govoplan-tenancy.tenant_palette_default_help">
<AppearancePaletteSelect
value={draft.appearance_palette}
onChange={(appearance_palette) => setDraft({
...draft,
appearance_palette,
effective_appearance_palette: appearance_palette ?? draft.system_appearance_palette,
effective_appearance_source: appearance_palette ? "tenant" : "system"
})}
allowInherit
disabled={!canWrite || busy || draft.system_appearance_palette_locked || (draft.appearance_palette_locked && !canWritePolicy)}
/>
</FormField>
<ToggleSwitch
checked={draft.appearance_palette_locked}
onChange={(appearance_palette_locked) => setDraft({ ...draft, appearance_palette_locked })}
disabled={!canWrite || !canWritePolicy || busy || draft.system_appearance_palette_locked}
help={!canWritePolicy ? "i18n:govoplan-tenancy.appearance_lock_policy_permission" : draft.system_appearance_palette_locked ? "i18n:govoplan-tenancy.system_palette_is_locked" : undefined}
label="i18n:govoplan-tenancy.lock_tenant_palette"
/>
<FormField
label="i18n:govoplan-tenancy.custom_overrides_policy"
help={!canWritePolicy ? "i18n:govoplan-tenancy.appearance_policy_permission" : "i18n:govoplan-tenancy.custom_overrides_policy_help"}
>
<select
value={draft.appearance_custom_overrides_allowed === null ? "inherit" : draft.appearance_custom_overrides_allowed ? "allow" : "block"}
disabled={!canWrite || !canWritePolicy || busy}
onChange={(event) => {
const appearance_custom_overrides_allowed = event.target.value === "inherit" ? null : event.target.value === "allow";
setDraft({
...draft,
appearance_custom_overrides_allowed,
effective_appearance_custom_overrides_allowed:
draft.system_appearance_custom_overrides_allowed
&& appearance_custom_overrides_allowed !== false
&& !draft.system_appearance_palette_locked
&& !draft.appearance_palette_locked
});
}}
>
<option value="inherit">i18n:govoplan-tenancy.inherit_system_policy</option>
<option value="allow" disabled={!draft.system_appearance_custom_overrides_allowed}>i18n:govoplan-tenancy.allow_for_tenant_users</option>
<option value="block">i18n:govoplan-tenancy.block_for_tenant_users</option>
</select>
</FormField>
<AppearancePalettePreview palette={draft.system_appearance_palette_locked ? draft.system_appearance_palette : draft.appearance_palette ?? draft.system_appearance_palette} />
<DescriptionList variant="inline">
<div><dt>i18n:govoplan-tenancy.effective_source</dt><dd>{draft.system_appearance_palette_locked ? "i18n:govoplan-tenancy.system_lock" : draft.appearance_palette ? "i18n:govoplan-tenancy.tenant_default" : "i18n:govoplan-tenancy.system_default"}</dd></div>
<div><dt>i18n:govoplan-tenancy.user_override</dt><dd>{draft.system_appearance_palette_locked || draft.appearance_palette_locked ? "i18n:govoplan-tenancy.blocked_by_policy" : "i18n:govoplan-tenancy.allowed"}</dd></div>
<div><dt>i18n:govoplan-tenancy.advanced_color_overrides</dt><dd>{customOverridesEffectivelyAllowed ? "i18n:govoplan-tenancy.allowed" : "i18n:govoplan-tenancy.blocked_by_policy"}</dd></div>
</DescriptionList>
</Card>
</div>
</AdminPageLayout>);
@@ -189,7 +288,11 @@ function localeOptions(current: string, enabled: string[]): string[] {
function tenantSettingsDraftKey(item: TenantSettingsItem): string {
return JSON.stringify({
default_locale: item.default_locale,
enabled_language_codes: item.enabled_language_codes
enabled_language_codes: item.enabled_language_codes,
navigation: item.navigation,
appearance_palette: item.appearance_palette,
appearance_palette_locked: item.appearance_palette_locked,
appearance_custom_overrides_allowed: item.appearance_custom_overrides_allowed
});
}
@@ -199,6 +302,8 @@ function applyTenantSettingsSections(item: TenantSettingsItem, sections: TenantS
...(sections.identity ?? {}),
...(sections.locale ?? {}),
...(sections.languages ?? {}),
...(sections.navigation !== undefined ? { navigation: sections.navigation } : {}),
...(sections.appearance ?? {}),
...(sections.settings ? { settings: sections.settings } : {})
};
}
+18 -17
View File
@@ -1,3 +1,4 @@
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo, TenantAdminItem } from "@govoplan/core-webui";
@@ -269,12 +270,12 @@ export default function TenantsPanel({
return (
<>
<AdminPageLayout
title="i18n:govoplan-tenancy.tenants.1f7ae776"
title="i18n:govoplan-tenancy.tenants.1f7ae776" titleHelp={<DocumentationHelpLink reference={TENANCY_ADMIN_DOCUMENTATION} />}
description="i18n:govoplan-tenancy.create_and_govern_tenant_spaces_suspension_retai.1b76d377"
loading={loading}
error={error}
success={success}
actions={<><DocumentationHelpLink reference={TENANCY_ADMIN_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading || busy} disabledReason={loading ? TENANCY_INTERFACE_I18N.loading : busy ? TENANCY_INTERFACE_I18N.busy : undefined}>i18n:govoplan-tenancy.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-tenancy.add_tenant.b8e32af0" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || busy} disabledReason={busy ? TENANCY_INTERFACE_I18N.busy : !canCreate ? TENANCY_INTERFACE_I18N.createRequired : undefined} /></>}>
actions={<><Button onClick={() => void load()} disabled={loading || busy} disabledReason={loading ? TENANCY_INTERFACE_I18N.loading : busy ? TENANCY_INTERFACE_I18N.busy : undefined}>i18n:govoplan-tenancy.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-tenancy.add_tenant.b8e32af0" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || busy} disabledReason={busy ? TENANCY_INTERFACE_I18N.busy : !canCreate ? TENANCY_INTERFACE_I18N.createRequired : undefined} /></>}>
{!canCreate && !canUpdate && !canSuspend && <ActionBlockerHint
reason={{
@@ -294,35 +295,35 @@ export default function TenantsPanel({
<div className="admin-table-surface"><DataGrid id="admin-tenants-v3" rows={tenants} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-tenancy.no_tenants_found.72d04cf4" /></div>
</AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-tenancy.create_tenant.4dbd55d9" : "i18n:govoplan-tenancy.edit_tenant.e2ba43f9"} onClose={requestCloseEditor} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={requestCloseEditor} disabled={busy} disabledReason={busy ? TENANCY_INTERFACE_I18N.busy : undefined}>i18n:govoplan-tenancy.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{busy ? "i18n:govoplan-tenancy.saving.56a2285c" : "i18n:govoplan-tenancy.save_tenant.9eb2ac74"}</Button></>}>
<div className="admin-form-grid two-columns">
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-tenancy.create_tenant.4dbd55d9" : "i18n:govoplan-tenancy.edit_tenant.e2ba43f9"} onClose={requestCloseEditor} className="" footer={<><Button onClick={requestCloseEditor} disabled={busy} disabledReason={busy ? TENANCY_INTERFACE_I18N.busy : undefined}>i18n:govoplan-tenancy.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{busy ? "i18n:govoplan-tenancy.saving.56a2285c" : "i18n:govoplan-tenancy.save_tenant.9eb2ac74"}</Button></>}>
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
<FormField label="i18n:govoplan-tenancy.name.709a2322" help={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} documentation={TENANCY_FIELD_DOCUMENTATION}><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-tenancy.slug.094da9b9" help={editing !== "new" ? "i18n:govoplan-tenancy.the_tenant_slug_is_immutable_after_creation.7c9a1025" : !canCreate ? TENANCY_INTERFACE_I18N.createRequired : undefined} documentation={TENANCY_FIELD_DOCUMENTATION}><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
{editing === "new" && <FormField label="i18n:govoplan-tenancy.initial_tenant_owner.682291a9" documentation={TENANCY_FIELD_DOCUMENTATION}><select value={draft.ownerAccountId} onChange={(event) => setDraft({ ...draft, ownerAccountId: event.target.value })}>{ownerCandidates.map((candidate) => <option key={candidate.account_id} value={candidate.account_id}>{candidate.display_name ? i18nMessage("i18n:govoplan-tenancy.value_value.c189e8bc", { value0: candidate.display_name, value1: candidate.email }) : candidate.email}</option>)}</select></FormField>}
<FormField label="i18n:govoplan-tenancy.default_locale.b99d021f" help={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} documentation={TENANCY_FIELD_DOCUMENTATION}><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></FormField>
{editing !== "new" && <FormField label="i18n:govoplan-tenancy.status.bae7d5be" help={!canSuspend ? TENANCY_INTERFACE_I18N.suspendRequired : undefined} documentation={TENANCY_ADMIN_DOCUMENTATION}><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-tenancy.active.a733b809</option><option value="inactive">i18n:govoplan-tenancy.suspended.794696a7</option></select></FormField>}
<FormField label="i18n:govoplan-tenancy.description.55f8ebc8" help={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} documentation={TENANCY_FIELD_DOCUMENTATION}><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div>
</FormGrid>
<h3>i18n:govoplan-tenancy.system_governance_overrides.97cdf3ce</h3>
<div className="admin-form-grid two-columns">
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
<GovernanceSelect disabled={editing !== "new" && !canUpdate} disabledReason={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} allowDisabled={!systemAllowsCustomGroups} label="i18n:govoplan-tenancy.custom_tenant_groups.570ee603" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} disabledReason={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} allowDisabled={!systemAllowsCustomRoles} label="i18n:govoplan-tenancy.custom_tenant_roles.a738c37c" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} disabledReason={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} allowDisabled={!systemAllowsApiKeys} label="i18n:govoplan-tenancy.tenant_api_keys.4b1d81f8" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
</div>
</FormGrid>
<p className="muted small-note">i18n:govoplan-tenancy.inherit_follows_the_current_system_setting_expli.60d4d868</p>
{systemDeniedGovernance && <p className="muted small-note"><span>i18n:govoplan-tenancy.explicit_allow_is_unavailable_for.8d05fd4a</span>{" "}{systemDeniedGovernance}{" "}<span>i18n:govoplan-tenancy.because_the_current_system_setting_denies_it.3f59c244</span></p>}
</Dialog>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-tenancy.tenant_details.5976ba72" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-tenancy.close.bbfa773e</Button>}>
{viewing && <><dl className="admin-details-grid">
<div><dt>i18n:govoplan-tenancy.tenant.3ca93c78</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-tenancy.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
<div><dt>i18n:govoplan-tenancy.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-tenancy.active.a733b809" : "i18n:govoplan-tenancy.suspended.794696a7"}</dd></div><div><dt>i18n:govoplan-tenancy.default_locale.b99d021f</dt><dd>{viewing.default_locale}</dd></div>
<div><dt>i18n:govoplan-tenancy.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-tenancy.updated.f2f8570d</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
<div><dt>i18n:govoplan-tenancy.custom_groups.1f7b7c8f</dt><dd>{viewing.effective_governance.allow_custom_groups ? "i18n:govoplan-tenancy.allowed.77c7b490" : "i18n:govoplan-tenancy.denied.63b16bd4"} ({overrideLabel(fromOverride(viewing.allow_custom_groups))})</dd></div>
<div><dt>i18n:govoplan-tenancy.custom_roles.e78ef63d</dt><dd>{viewing.effective_governance.allow_custom_roles ? "i18n:govoplan-tenancy.allowed.77c7b490" : "i18n:govoplan-tenancy.denied.63b16bd4"} ({overrideLabel(fromOverride(viewing.allow_custom_roles))})</dd></div>
<div><dt>i18n:govoplan-tenancy.api_keys.94fcf3c2</dt><dd>{viewing.effective_governance.allow_api_keys ? "i18n:govoplan-tenancy.allowed.77c7b490" : "i18n:govoplan-tenancy.denied.63b16bd4"} ({overrideLabel(fromOverride(viewing.allow_api_keys))})</dd></div>
<div><dt>i18n:govoplan-tenancy.objects.72a83add</dt><dd>{viewing.counts.users ?? 0}{" "}<span>i18n:govoplan-tenancy.users.81651889</span>{" "}{viewing.counts.groups ?? 0}{" "}<span>i18n:govoplan-tenancy.groups.07551586</span>{" "}{viewing.counts.campaigns ?? 0}{" "}<span>i18n:govoplan-tenancy.campaigns.2282ffeb</span>{" "}{viewing.counts.files ?? 0}{" "}<span>i18n:govoplan-tenancy.files_lowercase.7c9a1026</span></dd></div>
</dl>{viewing.description && <p>{viewing.description}</p>}</>}
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-tenancy.tenant_details.5976ba72" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-tenancy.close.bbfa773e</Button>}>
{viewing && <><DescriptionList>
<DescriptionItem term={<>i18n:govoplan-tenancy.tenant.3ca93c78</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-tenancy.slug.094da9b9</>}>{viewing.slug}</DescriptionItem>
<DescriptionItem term={<>i18n:govoplan-tenancy.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-tenancy.active.a733b809" : "i18n:govoplan-tenancy.suspended.794696a7"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-tenancy.default_locale.b99d021f</>}>{viewing.default_locale}</DescriptionItem>
<DescriptionItem term={<>i18n:govoplan-tenancy.created.accf40c8</>}>{formatDateTime(viewing.created_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-tenancy.updated.f2f8570d</>}>{formatDateTime(viewing.updated_at)}</DescriptionItem>
<DescriptionItem term={<>i18n:govoplan-tenancy.custom_groups.1f7b7c8f</>}>{viewing.effective_governance.allow_custom_groups ? "i18n:govoplan-tenancy.allowed.77c7b490" : "i18n:govoplan-tenancy.denied.63b16bd4"} ({overrideLabel(fromOverride(viewing.allow_custom_groups))})</DescriptionItem>
<DescriptionItem term={<>i18n:govoplan-tenancy.custom_roles.e78ef63d</>}>{viewing.effective_governance.allow_custom_roles ? "i18n:govoplan-tenancy.allowed.77c7b490" : "i18n:govoplan-tenancy.denied.63b16bd4"} ({overrideLabel(fromOverride(viewing.allow_custom_roles))})</DescriptionItem>
<DescriptionItem term={<>i18n:govoplan-tenancy.api_keys.94fcf3c2</>}>{viewing.effective_governance.allow_api_keys ? "i18n:govoplan-tenancy.allowed.77c7b490" : "i18n:govoplan-tenancy.denied.63b16bd4"} ({overrideLabel(fromOverride(viewing.allow_api_keys))})</DescriptionItem>
<DescriptionItem term={<>i18n:govoplan-tenancy.objects.72a83add</>}>{viewing.counts.users ?? 0}{" "}<span>i18n:govoplan-tenancy.users.81651889</span>{" "}{viewing.counts.groups ?? 0}{" "}<span>i18n:govoplan-tenancy.groups.07551586</span>{" "}{viewing.counts.campaigns ?? 0}{" "}<span>i18n:govoplan-tenancy.campaigns.2282ffeb</span>{" "}{viewing.counts.files ?? 0}{" "}<span>i18n:govoplan-tenancy.files_lowercase.7c9a1026</span></DescriptionItem>
</DescriptionList>{viewing.description && <p>{viewing.description}</p>}</>}
</Dialog>
<ConfirmDialog open={Boolean(confirmSuspend)} title="i18n:govoplan-tenancy.suspend_tenant.151d283a" message={i18nMessage("i18n:govoplan-tenancy.suspend_value_existing_data_remains_retained_but.19bccd78", { value0: confirmSuspend?.name })} confirmLabel="i18n:govoplan-tenancy.suspend_tenant.151d283a" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} />
+40
View File
@@ -2,6 +2,26 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-tenancy.appearance_defaults": "Appearance defaults",
"i18n:govoplan-tenancy.tenant_palette_default": "Tenant palette default",
"i18n:govoplan-tenancy.tenant_palette_default_help": "Inherit the system palette or select the default for this tenant.",
"i18n:govoplan-tenancy.appearance_lock_policy_permission": "Policy-write permission is required to change this lock.",
"i18n:govoplan-tenancy.custom_overrides_policy": "Personal color override policy",
"i18n:govoplan-tenancy.custom_overrides_policy_help": "Inherit the system decision, or explicitly allow or block the validated advanced editor for this tenant.",
"i18n:govoplan-tenancy.appearance_policy_permission": "Policy-write permission is required to change this appearance policy.",
"i18n:govoplan-tenancy.inherit_system_policy": "Inherit system policy",
"i18n:govoplan-tenancy.allow_for_tenant_users": "Allow for tenant users",
"i18n:govoplan-tenancy.block_for_tenant_users": "Block for tenant users",
"i18n:govoplan-tenancy.advanced_color_overrides": "Advanced color overrides",
"i18n:govoplan-tenancy.system_palette_is_locked": "The system palette is locked and takes precedence.",
"i18n:govoplan-tenancy.lock_tenant_palette": "Lock the tenant palette",
"i18n:govoplan-tenancy.effective_source": "Effective source",
"i18n:govoplan-tenancy.system_lock": "System policy lock",
"i18n:govoplan-tenancy.tenant_default": "Tenant default",
"i18n:govoplan-tenancy.system_default": "System default",
"i18n:govoplan-tenancy.user_override": "Personal choice",
"i18n:govoplan-tenancy.blocked_by_policy": "Blocked by policy",
"i18n:govoplan-tenancy.allowed": "Allowed",
"i18n:govoplan-tenancy.tenant_information_is_loading.7c9a1001": "Tenant information is loading.",
"i18n:govoplan-tenancy.a_tenant_change_is_in_progress.7c9a1002": "A tenant change is in progress.",
"i18n:govoplan-tenancy.tenant_creation_permission_is_required.7c9a1003": "Tenant creation permission is required.",
@@ -101,6 +121,26 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-tenancy.value_value.c189e8bc": "{value0} ({value1})"
},
"de": {
"i18n:govoplan-tenancy.appearance_defaults": "Darstellungsstandards",
"i18n:govoplan-tenancy.tenant_palette_default": "Mandantenstandard für die Farbpalette",
"i18n:govoplan-tenancy.tenant_palette_default_help": "Systempalette übernehmen oder einen Standard für diesen Mandanten auswählen.",
"i18n:govoplan-tenancy.appearance_lock_policy_permission": "Zum Ändern dieser Sperre ist das Recht zum Bearbeiten von Richtlinien erforderlich.",
"i18n:govoplan-tenancy.custom_overrides_policy": "Richtlinie für persönliche Farbanpassungen",
"i18n:govoplan-tenancy.custom_overrides_policy_help": "Die Systementscheidung übernehmen oder den geprüften erweiterten Editor für diesen Mandanten ausdrücklich zulassen oder sperren.",
"i18n:govoplan-tenancy.appearance_policy_permission": "Zum Ändern dieser Darstellungsrichtlinie ist das Recht zum Bearbeiten von Richtlinien erforderlich.",
"i18n:govoplan-tenancy.inherit_system_policy": "Systemrichtlinie übernehmen",
"i18n:govoplan-tenancy.allow_for_tenant_users": "Für Mandantenbenutzer zulassen",
"i18n:govoplan-tenancy.block_for_tenant_users": "Für Mandantenbenutzer sperren",
"i18n:govoplan-tenancy.advanced_color_overrides": "Erweiterte Farbanpassungen",
"i18n:govoplan-tenancy.system_palette_is_locked": "Die Systempalette ist verbindlich und hat Vorrang.",
"i18n:govoplan-tenancy.lock_tenant_palette": "Mandantenpalette verbindlich festlegen",
"i18n:govoplan-tenancy.effective_source": "Wirksame Quelle",
"i18n:govoplan-tenancy.system_lock": "Systemrichtlinie",
"i18n:govoplan-tenancy.tenant_default": "Mandantenstandard",
"i18n:govoplan-tenancy.system_default": "Systemstandard",
"i18n:govoplan-tenancy.user_override": "Persönliche Auswahl",
"i18n:govoplan-tenancy.blocked_by_policy": "Durch Richtlinie gesperrt",
"i18n:govoplan-tenancy.allowed": "Zulässig",
"i18n:govoplan-tenancy.tenant_information_is_loading.7c9a1001": "Mandanteninformationen werden geladen.",
"i18n:govoplan-tenancy.a_tenant_change_is_in_progress.7c9a1002": "Eine Mandantenänderung wird gerade ausgeführt.",
"i18n:govoplan-tenancy.tenant_creation_permission_is_required.7c9a1003": "Die Berechtigung zum Erstellen von Mandanten ist erforderlich.",
+1
View File
@@ -44,6 +44,7 @@ const adminSections: AdminSectionsUiCapability = {
createElement(TenantSettingsPanel, {
settings,
canWrite: auth.scopes.includes("admin:settings:write"),
canWritePolicy: auth.scopes.includes("admin:policies:write"),
onAuthRefresh: refreshAuth
})
}