feat: orchestrate governed tenant erasure
This commit is contained in:
@@ -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
|
||||
@@ -33,6 +33,7 @@ from govoplan_core.core.navigation import (
|
||||
)
|
||||
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,
|
||||
@@ -50,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,
|
||||
@@ -66,6 +81,12 @@ from .schemas import (
|
||||
TenantCreateRequest,
|
||||
TenantDeleteRequest,
|
||||
TenantDeletionPlanResponse,
|
||||
TenantErasureApprovalRequest,
|
||||
TenantErasureExecutionRequest,
|
||||
TenantErasureOperationResponse,
|
||||
TenantErasurePolicyResponse,
|
||||
TenantErasurePolicyUpdateRequest,
|
||||
TenantErasurePreviewRequest,
|
||||
TenantLifecycleIssue,
|
||||
TenantLifecycleResponse,
|
||||
TenantListDeltaResponse,
|
||||
@@ -818,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,
|
||||
@@ -829,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"
|
||||
@@ -837,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({
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,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",
|
||||
]
|
||||
@@ -1,5 +1,7 @@
|
||||
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
|
||||
|
||||
@@ -14,11 +16,18 @@ from govoplan_core.core.modules import (
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
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
|
||||
|
||||
|
||||
def _tenant_resolver(context: ModuleContext):
|
||||
@@ -42,7 +51,7 @@ def _route_factory(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="tenancy",
|
||||
name="Tenancy",
|
||||
version="0.1.20",
|
||||
version="0.1.21",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -52,6 +61,26 @@ manifest = ModuleManifest(
|
||||
capability_factories={
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER: _tenant_resolver,
|
||||
},
|
||||
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.current-context",
|
||||
@@ -84,7 +113,7 @@ 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. 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.",
|
||||
body="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.",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("system_admin", "tenant_admin", "operator"),
|
||||
related_modules=("access", "admin", "audit"),
|
||||
@@ -96,6 +125,7 @@ manifest = ModuleManifest(
|
||||
"access:tenant:update",
|
||||
"access:setting:read",
|
||||
"access:setting:write",
|
||||
"system:tenants:erase",
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -113,6 +143,11 @@ manifest = ModuleManifest(
|
||||
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": {
|
||||
@@ -123,7 +158,13 @@ manifest = ModuleManifest(
|
||||
),
|
||||
"body": (
|
||||
"Ein Mandant ist eine konkrete Administrations- und Datengrenze. Änderungen am Mandantenlebenszyklus müssen Eigentums- "
|
||||
"und Wiederherstellungsgarantien für modulbezogene Datensätze bewahren. Neue Mandanten verwenden standardmäßig die "
|
||||
"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. "
|
||||
@@ -148,14 +189,90 @@ manifest = ModuleManifest(
|
||||
"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."
|
||||
),
|
||||
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."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["tenancy.admin.tenant-erasure"],
|
||||
},
|
||||
),
|
||||
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. 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.",
|
||||
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"),
|
||||
@@ -184,7 +301,8 @@ manifest = ModuleManifest(
|
||||
"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."
|
||||
"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."
|
||||
),
|
||||
}
|
||||
},
|
||||
@@ -198,6 +316,7 @@ manifest = ModuleManifest(
|
||||
"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.",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tenancy module migrations."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Tenancy module migration revisions."""
|
||||
+104
@@ -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")
|
||||
Reference in New Issue
Block a user