Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dde038547 | |||
| efbec82761 | |||
| 0ca9bd793c | |||
| cd7cc674c7 |
@@ -1,5 +1,9 @@
|
||||
# GovOPlaN Tenancy
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-tenancy` owns tenant lifecycle, tenant administration API route
|
||||
contributions, and the `tenancy.tenantResolver` capability during the GovOPlaN
|
||||
module split.
|
||||
|
||||
@@ -27,3 +27,36 @@ their owning modules before physical deletion.
|
||||
Tenant lifecycle planning uses registered tenant summary providers and delete
|
||||
veto providers. Modules that own tenant-scoped data must contribute summaries
|
||||
so destructive deletion cannot silently miss their rows.
|
||||
|
||||
Delete veto providers are registered through module manifests and receive
|
||||
`(session, tenant_id, resource_id)`. Providers should return a structured
|
||||
`DeleteVetoIssue`, a list of issues, or `None`; legacy providers that raise an
|
||||
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.
|
||||
|
||||
## Lifecycle Events
|
||||
|
||||
`govoplan-tenancy.backend.lifecycle` is the module-local contract for tenant
|
||||
lifecycle event names and payload shape. Modules that need to react to tenant
|
||||
lifecycle changes should depend on the event type strings or the emitted audit
|
||||
events, not on tenancy API route internals.
|
||||
|
||||
The stable lifecycle event names are:
|
||||
|
||||
- `tenant.created`: tenant registry entry was created and owner membership
|
||||
provisioning was requested.
|
||||
- `tenant.suspended`: tenant was marked inactive through the admin lifecycle
|
||||
route.
|
||||
- `tenant.resumed`: tenant was reactivated through the admin lifecycle route.
|
||||
- `tenant.deletion_requested`: retirement or destructive erasure was requested
|
||||
after lifecycle planning passed.
|
||||
- `tenant.erasure_completed`: destructive tenant deletion completed.
|
||||
|
||||
Legacy audit actions such as `tenant.updated`, `tenant.retired`, and
|
||||
`tenant.destroyed` can still be emitted for compatibility. New module behavior
|
||||
should key off the explicit lifecycle events above.
|
||||
|
||||
Lifecycle event details use concrete tenant identifiers plus optional actor,
|
||||
reason, count, and mode information. Destructive erasure is only emitted after
|
||||
the tenant row is successfully scheduled for deletion in the same transaction.
|
||||
|
||||
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-tenancy"
|
||||
version = "0.1.6"
|
||||
version = "0.1.8"
|
||||
description = "GovOPlaN tenancy platform module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.6",
|
||||
"govoplan-core>=0.1.8",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -32,6 +32,13 @@ from govoplan_core.tenancy.service import (
|
||||
tenant_counts,
|
||||
)
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
from govoplan_tenancy.backend.lifecycle import (
|
||||
TENANT_EVENT_CREATED,
|
||||
TENANT_EVENT_DELETION_REQUESTED,
|
||||
TENANT_EVENT_ERASURE_COMPLETED,
|
||||
tenant_lifecycle_event,
|
||||
tenant_lifecycle_event_type,
|
||||
)
|
||||
|
||||
from .schemas import (
|
||||
TenantAdminItem,
|
||||
@@ -63,6 +70,16 @@ 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_NON_STATUS_UPDATE_FIELDS = {
|
||||
"name",
|
||||
"description",
|
||||
"default_locale",
|
||||
"settings",
|
||||
"allow_custom_groups",
|
||||
"allow_custom_roles",
|
||||
"allow_api_keys",
|
||||
}
|
||||
TENANT_GOVERNANCE_OVERRIDE_FIELDS = ("allow_custom_groups", "allow_custom_roles", "allow_api_keys")
|
||||
|
||||
|
||||
def _tenant_access_provisioner() -> TenantAccessProvisioner:
|
||||
@@ -90,6 +107,20 @@ def _require_permission(principal: ApiPrincipal, scope: str) -> None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _require_tenant_update_permissions(principal: ApiPrincipal, payload: TenantUpdateRequest) -> None:
|
||||
if payload.model_fields_set.intersection(TENANT_NON_STATUS_UPDATE_FIELDS):
|
||||
_require_permission(principal, "system:tenants:update")
|
||||
if payload.is_active is not None:
|
||||
_require_permission(principal, "system:tenants:suspend")
|
||||
|
||||
|
||||
def _tenant_or_404(session: Session, tenant_id: str) -> Tenant:
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
return tenant
|
||||
|
||||
|
||||
def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem:
|
||||
governance = effective_tenant_governance(session, tenant)
|
||||
return TenantAdminItem(
|
||||
@@ -149,7 +180,16 @@ def _tenant_deletion_plan(session: Session, tenant: Tenant, principal: ApiPrinci
|
||||
))
|
||||
|
||||
registry = get_registry()
|
||||
if registry is not None and hasattr(registry, "delete_veto_providers"):
|
||||
if registry is not None and hasattr(registry, "collect_delete_veto_issues"):
|
||||
for issue in registry.collect_delete_veto_issues("tenant", session, tenant.id, tenant.id):
|
||||
issues.append(TenantLifecycleIssue(
|
||||
severity=issue.severity,
|
||||
code=issue.code,
|
||||
message=issue.message,
|
||||
module_id=issue.module_id,
|
||||
details=dict(issue.details),
|
||||
))
|
||||
elif registry is not None and hasattr(registry, "delete_veto_providers"):
|
||||
for provider in registry.delete_veto_providers("tenant"):
|
||||
try:
|
||||
provider(session, tenant.id, tenant.id)
|
||||
@@ -457,17 +497,69 @@ def create_tenant(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
user_id=principal.user.id,
|
||||
action="tenant.created",
|
||||
action=TENANT_EVENT_CREATED,
|
||||
object_type="tenant",
|
||||
object_id=tenant.id,
|
||||
scope="system",
|
||||
details={"slug": tenant.slug, "name": tenant.name, "creator_account_id": principal.account_id, "owner_account_id": owner_account_id},
|
||||
details=tenant_lifecycle_event(
|
||||
"created",
|
||||
tenant_id=tenant.id,
|
||||
tenant_slug=tenant.slug,
|
||||
tenant_name=tenant.name,
|
||||
actor_account_id=principal.account_id,
|
||||
requested_by_account_id=owner_account_id,
|
||||
details={"creator_account_id": principal.account_id, "owner_account_id": owner_account_id},
|
||||
).audit_details(),
|
||||
)
|
||||
_record_tenant_list_change(session, tenant=tenant, operation="created", principal=principal)
|
||||
session.commit()
|
||||
return _tenant_item(session, tenant)
|
||||
|
||||
|
||||
def _apply_tenant_content_updates(tenant: Tenant, payload: TenantUpdateRequest) -> None:
|
||||
if payload.name is not None:
|
||||
tenant.name = payload.name.strip()
|
||||
if "description" in payload.model_fields_set:
|
||||
tenant.description = _normalized_optional_text(payload.description)
|
||||
if payload.default_locale is not None:
|
||||
tenant.default_locale = _normalized_tenant_locale(payload.default_locale)
|
||||
if payload.settings is not None:
|
||||
tenant.settings = payload.settings
|
||||
|
||||
|
||||
def _normalized_optional_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.strip()
|
||||
return clean or None
|
||||
|
||||
|
||||
def _normalized_tenant_locale(value: str) -> str:
|
||||
return value.strip() or "en"
|
||||
|
||||
|
||||
def _apply_tenant_governance_updates(session: Session, tenant: Tenant, payload: TenantUpdateRequest) -> None:
|
||||
try:
|
||||
for field in TENANT_GOVERNANCE_OVERRIDE_FIELDS:
|
||||
if field in payload.model_fields_set:
|
||||
value = getattr(payload, field)
|
||||
assert_tenant_governance_override_allowed(session, field=field, value=value)
|
||||
setattr(tenant, field, value)
|
||||
except AdminValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _apply_tenant_status_update(tenant: Tenant, payload: TenantUpdateRequest, principal: ApiPrincipal) -> None:
|
||||
if payload.is_active is None:
|
||||
return
|
||||
if not payload.is_active and tenant.id == principal.tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Switch to another tenant before suspending the active tenant.",
|
||||
)
|
||||
tenant.is_active = payload.is_active
|
||||
|
||||
|
||||
@router.patch("/tenants/{tenant_id}", response_model=TenantAdminItem)
|
||||
def update_tenant(
|
||||
tenant_id: str,
|
||||
@@ -475,42 +567,13 @@ def update_tenant(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
non_status_fields = {"name", "description", "default_locale", "settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"}
|
||||
if payload.model_fields_set.intersection(non_status_fields):
|
||||
_require_permission(principal, "system:tenants:update")
|
||||
if payload.is_active is not None:
|
||||
_require_permission(principal, "system:tenants:suspend")
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
_require_tenant_update_permissions(principal, payload)
|
||||
tenant = _tenant_or_404(session, tenant_id)
|
||||
was_active = tenant.is_active
|
||||
before_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant))
|
||||
if payload.name is not None:
|
||||
tenant.name = payload.name.strip()
|
||||
if "description" in payload.model_fields_set:
|
||||
tenant.description = payload.description.strip() if payload.description and payload.description.strip() else None
|
||||
if payload.default_locale is not None:
|
||||
tenant.default_locale = payload.default_locale.strip() or "en"
|
||||
if payload.settings is not None:
|
||||
tenant.settings = payload.settings
|
||||
try:
|
||||
if "allow_custom_groups" in payload.model_fields_set:
|
||||
assert_tenant_governance_override_allowed(session, field="allow_custom_groups", value=payload.allow_custom_groups)
|
||||
tenant.allow_custom_groups = payload.allow_custom_groups
|
||||
if "allow_custom_roles" in payload.model_fields_set:
|
||||
assert_tenant_governance_override_allowed(session, field="allow_custom_roles", value=payload.allow_custom_roles)
|
||||
tenant.allow_custom_roles = payload.allow_custom_roles
|
||||
if "allow_api_keys" in payload.model_fields_set:
|
||||
assert_tenant_governance_override_allowed(session, field="allow_api_keys", value=payload.allow_api_keys)
|
||||
tenant.allow_api_keys = payload.allow_api_keys
|
||||
except AdminValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
if payload.is_active is not None:
|
||||
if not payload.is_active and tenant.id == principal.tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Switch to another tenant before suspending the active tenant.",
|
||||
)
|
||||
tenant.is_active = payload.is_active
|
||||
_apply_tenant_content_updates(tenant, payload)
|
||||
_apply_tenant_governance_updates(session, tenant, payload)
|
||||
_apply_tenant_status_update(tenant, payload, principal)
|
||||
session.add(tenant)
|
||||
audit_event(
|
||||
session,
|
||||
@@ -522,6 +585,25 @@ def update_tenant(
|
||||
object_id=tenant.id,
|
||||
details=payload.model_dump(exclude_unset=True),
|
||||
)
|
||||
if payload.is_active is not None and payload.is_active != was_active:
|
||||
lifecycle_phase = "resumed" if payload.is_active else "suspended"
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
user_id=principal.user.id,
|
||||
action=tenant_lifecycle_event_type(lifecycle_phase),
|
||||
scope="system",
|
||||
object_type="tenant",
|
||||
object_id=tenant.id,
|
||||
details=tenant_lifecycle_event(
|
||||
lifecycle_phase,
|
||||
tenant_id=tenant.id,
|
||||
tenant_slug=tenant.slug,
|
||||
tenant_name=tenant.name,
|
||||
actor_account_id=principal.account_id,
|
||||
details={"previous_is_active": was_active, "is_active": tenant.is_active},
|
||||
).audit_details(),
|
||||
)
|
||||
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)
|
||||
_record_tenant_list_change(session, tenant=tenant, operation="updated", principal=principal)
|
||||
@@ -562,6 +644,26 @@ def retire_tenant(
|
||||
|
||||
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,
|
||||
@@ -572,6 +674,25 @@ def retire_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()
|
||||
@@ -590,6 +711,25 @@ def retire_tenant(
|
||||
tenant.settings = settings_payload
|
||||
tenant.is_active = False
|
||||
session.add(tenant)
|
||||
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,
|
||||
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(),
|
||||
)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
|
||||
@@ -79,6 +79,7 @@ class TenantLifecycleIssue(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
module_id: str | None = None
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TenantDeletionPlanResponse(BaseModel):
|
||||
|
||||
99
src/govoplan_tenancy/backend/lifecycle.py
Normal file
99
src/govoplan_tenancy/backend/lifecycle.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
TenantLifecyclePhase = Literal[
|
||||
"created",
|
||||
"suspended",
|
||||
"resumed",
|
||||
"deletion_requested",
|
||||
"erasure_completed",
|
||||
]
|
||||
|
||||
TENANT_EVENT_CREATED = "tenant.created"
|
||||
TENANT_EVENT_SUSPENDED = "tenant.suspended"
|
||||
TENANT_EVENT_RESUMED = "tenant.resumed"
|
||||
TENANT_EVENT_DELETION_REQUESTED = "tenant.deletion_requested"
|
||||
TENANT_EVENT_ERASURE_COMPLETED = "tenant.erasure_completed"
|
||||
|
||||
TENANT_LIFECYCLE_EVENTS: tuple[str, ...] = (
|
||||
TENANT_EVENT_CREATED,
|
||||
TENANT_EVENT_SUSPENDED,
|
||||
TENANT_EVENT_RESUMED,
|
||||
TENANT_EVENT_DELETION_REQUESTED,
|
||||
TENANT_EVENT_ERASURE_COMPLETED,
|
||||
)
|
||||
|
||||
_EVENT_BY_PHASE: Mapping[TenantLifecyclePhase, str] = {
|
||||
"created": TENANT_EVENT_CREATED,
|
||||
"suspended": TENANT_EVENT_SUSPENDED,
|
||||
"resumed": TENANT_EVENT_RESUMED,
|
||||
"deletion_requested": TENANT_EVENT_DELETION_REQUESTED,
|
||||
"erasure_completed": TENANT_EVENT_ERASURE_COMPLETED,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantLifecycleEvent:
|
||||
event_type: str
|
||||
tenant_id: str
|
||||
tenant_slug: str | None = None
|
||||
tenant_name: str | None = None
|
||||
actor_account_id: str | None = None
|
||||
requested_by_account_id: str | None = None
|
||||
reason: str | None = None
|
||||
counts: Mapping[str, int] = field(default_factory=dict)
|
||||
occurred_at: datetime | None = None
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def audit_details(self) -> dict[str, object]:
|
||||
payload: dict[str, object] = dict(self.details)
|
||||
if self.tenant_slug is not None:
|
||||
payload["slug"] = self.tenant_slug
|
||||
if self.tenant_name is not None:
|
||||
payload["name"] = self.tenant_name
|
||||
if self.actor_account_id is not None:
|
||||
payload["actor_account_id"] = self.actor_account_id
|
||||
if self.requested_by_account_id is not None:
|
||||
payload["requested_by_account_id"] = self.requested_by_account_id
|
||||
if self.reason is not None:
|
||||
payload["reason"] = self.reason
|
||||
if self.counts:
|
||||
payload["counts"] = dict(self.counts)
|
||||
if self.occurred_at is not None:
|
||||
payload["occurred_at"] = self.occurred_at.isoformat()
|
||||
return payload
|
||||
|
||||
|
||||
def tenant_lifecycle_event_type(phase: TenantLifecyclePhase) -> str:
|
||||
return _EVENT_BY_PHASE[phase]
|
||||
|
||||
|
||||
def tenant_lifecycle_event(
|
||||
phase: TenantLifecyclePhase,
|
||||
*,
|
||||
tenant_id: str,
|
||||
tenant_slug: str | None = None,
|
||||
tenant_name: str | None = None,
|
||||
actor_account_id: str | None = None,
|
||||
requested_by_account_id: str | None = None,
|
||||
reason: str | None = None,
|
||||
counts: Mapping[str, int] | None = None,
|
||||
occurred_at: datetime | None = None,
|
||||
details: Mapping[str, object] | None = None,
|
||||
) -> TenantLifecycleEvent:
|
||||
return TenantLifecycleEvent(
|
||||
event_type=tenant_lifecycle_event_type(phase),
|
||||
tenant_id=tenant_id,
|
||||
tenant_slug=tenant_slug,
|
||||
tenant_name=tenant_name,
|
||||
actor_account_id=actor_account_id,
|
||||
requested_by_account_id=requested_by_account_id,
|
||||
reason=reason,
|
||||
counts=dict(counts or {}),
|
||||
occurred_at=occurred_at,
|
||||
details=dict(details or {}),
|
||||
)
|
||||
@@ -30,7 +30,7 @@ def _route_factory(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="tenancy",
|
||||
name="Tenancy",
|
||||
version="0.1.6",
|
||||
version="0.1.8",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
|
||||
144
tests/test_tenant_lifecycle.py
Normal file
144
tests/test_tenant_lifecycle.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_tenancy.backend.api.v1.routes import (
|
||||
_apply_tenant_content_updates,
|
||||
_apply_tenant_status_update,
|
||||
_require_tenant_update_permissions,
|
||||
)
|
||||
from govoplan_tenancy.backend.api.v1.schemas import TenantUpdateRequest
|
||||
from govoplan_tenancy.backend.lifecycle import (
|
||||
TENANT_EVENT_CREATED,
|
||||
TENANT_EVENT_DELETION_REQUESTED,
|
||||
TENANT_EVENT_ERASURE_COMPLETED,
|
||||
TENANT_EVENT_RESUMED,
|
||||
TENANT_EVENT_SUSPENDED,
|
||||
TENANT_LIFECYCLE_EVENTS,
|
||||
tenant_lifecycle_event,
|
||||
tenant_lifecycle_event_type,
|
||||
)
|
||||
|
||||
|
||||
class FakePrincipal:
|
||||
def __init__(self, scopes: set[str], *, tenant_id: str = "tenant-1") -> None:
|
||||
self.scopes = frozenset(scopes)
|
||||
self.tenant_id = tenant_id
|
||||
|
||||
def has(self, required_scope: str) -> bool:
|
||||
return required_scope in self.scopes
|
||||
|
||||
|
||||
class TenantLifecycleContractTests(unittest.TestCase):
|
||||
def test_lifecycle_event_names_are_stable(self) -> None:
|
||||
self.assertEqual("tenant.created", tenant_lifecycle_event_type("created"))
|
||||
self.assertEqual("tenant.suspended", tenant_lifecycle_event_type("suspended"))
|
||||
self.assertEqual("tenant.resumed", tenant_lifecycle_event_type("resumed"))
|
||||
self.assertEqual("tenant.deletion_requested", tenant_lifecycle_event_type("deletion_requested"))
|
||||
self.assertEqual("tenant.erasure_completed", tenant_lifecycle_event_type("erasure_completed"))
|
||||
self.assertEqual(
|
||||
(
|
||||
TENANT_EVENT_CREATED,
|
||||
TENANT_EVENT_SUSPENDED,
|
||||
TENANT_EVENT_RESUMED,
|
||||
TENANT_EVENT_DELETION_REQUESTED,
|
||||
TENANT_EVENT_ERASURE_COMPLETED,
|
||||
),
|
||||
TENANT_LIFECYCLE_EVENTS,
|
||||
)
|
||||
|
||||
def test_lifecycle_event_builds_audit_details_without_losing_extra_fields(self) -> None:
|
||||
occurred_at = datetime(2026, 7, 11, 12, 30, tzinfo=UTC)
|
||||
|
||||
event = tenant_lifecycle_event(
|
||||
"deletion_requested",
|
||||
tenant_id="tenant-1",
|
||||
tenant_slug="city",
|
||||
tenant_name="City Office",
|
||||
actor_account_id="account-1",
|
||||
requested_by_account_id="account-2",
|
||||
reason="end of contract",
|
||||
counts={"files": 2},
|
||||
occurred_at=occurred_at,
|
||||
details={"mode": "retire"},
|
||||
)
|
||||
|
||||
self.assertEqual(TENANT_EVENT_DELETION_REQUESTED, event.event_type)
|
||||
self.assertEqual("tenant-1", event.tenant_id)
|
||||
self.assertEqual(
|
||||
{
|
||||
"mode": "retire",
|
||||
"slug": "city",
|
||||
"name": "City Office",
|
||||
"actor_account_id": "account-1",
|
||||
"requested_by_account_id": "account-2",
|
||||
"reason": "end of contract",
|
||||
"counts": {"files": 2},
|
||||
"occurred_at": "2026-07-11T12:30:00+00:00",
|
||||
},
|
||||
event.audit_details(),
|
||||
)
|
||||
|
||||
|
||||
class TenantUpdateHelperTests(unittest.TestCase):
|
||||
def test_tenant_update_permissions_separate_content_and_status_changes(self) -> None:
|
||||
_require_tenant_update_permissions(
|
||||
FakePrincipal({"system:tenants:suspend"}), # type: ignore[arg-type]
|
||||
TenantUpdateRequest(is_active=False),
|
||||
)
|
||||
_require_tenant_update_permissions(
|
||||
FakePrincipal({"system:tenants:update"}), # type: ignore[arg-type]
|
||||
TenantUpdateRequest(name="Updated"),
|
||||
)
|
||||
|
||||
with self.assertRaises(HTTPException) as missing_update:
|
||||
_require_tenant_update_permissions(FakePrincipal(set()), TenantUpdateRequest(name="Updated")) # type: ignore[arg-type]
|
||||
self.assertEqual(403, missing_update.exception.status_code)
|
||||
self.assertEqual("Missing scope: system:tenants:update", missing_update.exception.detail)
|
||||
|
||||
with self.assertRaises(HTTPException) as missing_suspend:
|
||||
_require_tenant_update_permissions(FakePrincipal(set()), TenantUpdateRequest(is_active=False)) # type: ignore[arg-type]
|
||||
self.assertEqual(403, missing_suspend.exception.status_code)
|
||||
self.assertEqual("Missing scope: system:tenants:suspend", missing_suspend.exception.detail)
|
||||
|
||||
def test_tenant_content_updates_normalize_blank_fields_and_defaults(self) -> None:
|
||||
tenant = SimpleNamespace(name="Old", description="Old description", default_locale="de", settings={})
|
||||
payload = TenantUpdateRequest(
|
||||
name=" New tenant ",
|
||||
description=" ",
|
||||
default_locale=" ",
|
||||
settings={"theme": "contrast"},
|
||||
)
|
||||
|
||||
_apply_tenant_content_updates(tenant, payload) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual("New tenant", tenant.name)
|
||||
self.assertIsNone(tenant.description)
|
||||
self.assertEqual("en", tenant.default_locale)
|
||||
self.assertEqual({"theme": "contrast"}, tenant.settings)
|
||||
|
||||
def test_tenant_status_update_prevents_suspending_current_tenant(self) -> None:
|
||||
tenant = SimpleNamespace(id="tenant-1", is_active=True)
|
||||
principal = FakePrincipal({"system:tenants:suspend"}, tenant_id="tenant-1")
|
||||
|
||||
with self.assertRaises(HTTPException) as captured:
|
||||
_apply_tenant_status_update(tenant, TenantUpdateRequest(is_active=False), principal) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(409, captured.exception.status_code)
|
||||
self.assertEqual("Switch to another tenant before suspending the active tenant.", captured.exception.detail)
|
||||
|
||||
def test_tenant_status_update_allows_other_tenant_suspension(self) -> None:
|
||||
tenant = SimpleNamespace(id="tenant-2", is_active=True)
|
||||
principal = FakePrincipal({"system:tenants:suspend"}, tenant_id="tenant-1")
|
||||
|
||||
_apply_tenant_status_update(tenant, TenantUpdateRequest(is_active=False), principal) # type: ignore[arg-type]
|
||||
|
||||
self.assertFalse(tenant.is_active)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user