2 Commits

Author SHA1 Message Date
0ca9bd793c Release v0.1.7 2026-07-11 02:34:58 +02:00
cd7cc674c7 Define tenant lifecycle event contract 2026-07-11 00:46:09 +02:00
7 changed files with 311 additions and 6 deletions

View File

@@ -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.

View File

@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-tenancy"
version = "0.1.6"
version = "0.1.7"
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.7",
]
[tool.setuptools.packages.find]

View File

@@ -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,
@@ -149,7 +156,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,11 +473,19 @@ 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()
@@ -483,6 +507,7 @@ def update_tenant(
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
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()
@@ -522,6 +547,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 +606,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 +636,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 +673,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,

View File

@@ -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):

View 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 {}),
)

View File

@@ -30,7 +30,7 @@ def _route_factory(context: ModuleContext):
manifest = ModuleManifest(
id="tenancy",
name="Tenancy",
version="0.1.6",
version="0.1.7",
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
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 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(),
)
if __name__ == "__main__":
unittest.main()