From cd7cc674c70c67c7389b77cf3f5e90fa06509b12 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 11 Jul 2026 00:46:09 +0200 Subject: [PATCH] Define tenant lifecycle event contract --- docs/TENANCY_MODULE_BOUNDARY.md | 26 +++++ src/govoplan_tenancy/backend/api/v1/routes.py | 97 +++++++++++++++++- src/govoplan_tenancy/backend/lifecycle.py | 99 +++++++++++++++++++ tests/test_tenant_lifecycle.py | 70 +++++++++++++ 4 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 src/govoplan_tenancy/backend/lifecycle.py create mode 100644 tests/test_tenant_lifecycle.py diff --git a/docs/TENANCY_MODULE_BOUNDARY.md b/docs/TENANCY_MODULE_BOUNDARY.md index 527b95e..6b12e34 100644 --- a/docs/TENANCY_MODULE_BOUNDARY.md +++ b/docs/TENANCY_MODULE_BOUNDARY.md @@ -27,3 +27,29 @@ 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. + +## 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. diff --git a/src/govoplan_tenancy/backend/api/v1/routes.py b/src/govoplan_tenancy/backend/api/v1/routes.py index 99bc378..0bbe5a4 100644 --- a/src/govoplan_tenancy/backend/api/v1/routes.py +++ b/src/govoplan_tenancy/backend/api/v1/routes.py @@ -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, @@ -457,11 +464,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 +498,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 +538,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 +597,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 +627,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 +664,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, diff --git a/src/govoplan_tenancy/backend/lifecycle.py b/src/govoplan_tenancy/backend/lifecycle.py new file mode 100644 index 0000000..3dc13a3 --- /dev/null +++ b/src/govoplan_tenancy/backend/lifecycle.py @@ -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 {}), + ) diff --git a/tests/test_tenant_lifecycle.py b/tests/test_tenant_lifecycle.py new file mode 100644 index 0000000..6a80fc2 --- /dev/null +++ b/tests/test_tenant_lifecycle.py @@ -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()