Add an explicit tenant lifecycle (active/retired), allow-listed mutable
metadata, record versioning, and lifecycle timestamps to the tenant authority.
- domain: TenantLifecycle, with_metadata/retire/reactivate, immutability and
transition invariants. Identifier stays immutable -- it is the IAM Profile
`tenant` claim key-cape mints into tokens.
- store: mutate_tenant() commits idempotency replay, version CAS, mutation,
and audit event together; durable receipts survive restart. Retired tenants
refuse new grants and plan changes but keep their history.
- sqlite: forward-only idempotent migration; existing rows default to active
at version 1. Reads now take the write lock -- the concurrent-writer test
caught unguarded reads on the shared connection observing mid-transaction
state as a spurious tenant_not_found.
- api: GET/PATCH /tenants/{id}, POST retire|reactivate. Idempotency-Key and
If-Match required, distinct flex-auth actions per operation, stable error
schema, redacted 503s.
- docs/tenant-lifecycle-api.md: consumer contract for user-engine.
Implemented against SQLite, not PostgreSQL as the workplan assumed --
TEN-WP-0004 shipped SQLite on a PVC as the production store.
124 tests pass (was 66); no breaking change to existing endpoints.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
88 lines
2.7 KiB
Python
88 lines
2.7 KiB
Python
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
|
|
from tenant_engine.domain import (
|
|
EmptyUpdateError,
|
|
ImmutableFieldError,
|
|
InvalidLifecycleTransitionError,
|
|
Tenant,
|
|
TenantLifecycle,
|
|
)
|
|
|
|
NOW = datetime(2026, 8, 10, 12, 0, tzinfo=UTC)
|
|
|
|
|
|
def _tenant() -> Tenant:
|
|
return Tenant.create(
|
|
tenant_id="t-1", identifier="tenant:friendly:binky", display_name="Binky", created_at=NOW
|
|
)
|
|
|
|
|
|
def test_new_tenant_is_active_at_version_one() -> None:
|
|
tenant = _tenant()
|
|
assert tenant.lifecycle is TenantLifecycle.ACTIVE
|
|
assert tenant.version == 1
|
|
assert tenant.is_active
|
|
|
|
|
|
def test_metadata_update_bumps_version_and_keeps_identity() -> None:
|
|
tenant = _tenant().with_metadata({"display_name": "Binky Ltd"}, at=NOW)
|
|
|
|
assert tenant.display_name == "Binky Ltd"
|
|
assert tenant.version == 2
|
|
assert tenant.updated_at == NOW
|
|
assert tenant.tenant_id == "t-1"
|
|
assert tenant.identifier == "tenant:friendly:binky"
|
|
assert tenant.grouping == "friendly"
|
|
|
|
|
|
def test_update_rejects_identifier_mutation() -> None:
|
|
with pytest.raises(ImmutableFieldError):
|
|
_tenant().with_metadata({"identifier": "tenant:large:other"}, at=NOW)
|
|
|
|
|
|
def test_update_rejects_unknown_field() -> None:
|
|
with pytest.raises(ImmutableFieldError):
|
|
_tenant().with_metadata({"nickname": "binks"}, at=NOW)
|
|
|
|
|
|
def test_update_rejects_empty_change_set() -> None:
|
|
with pytest.raises(EmptyUpdateError):
|
|
_tenant().with_metadata({}, at=NOW)
|
|
|
|
|
|
def test_update_rejects_no_op_change() -> None:
|
|
with pytest.raises(EmptyUpdateError):
|
|
_tenant().with_metadata({"display_name": "Binky"}, at=NOW)
|
|
|
|
|
|
def test_retire_is_reversible_and_preserves_identity() -> None:
|
|
retired = _tenant().retire(at=NOW)
|
|
assert retired.lifecycle is TenantLifecycle.RETIRED
|
|
assert retired.retired_at == NOW
|
|
assert retired.version == 2
|
|
assert retired.identifier == "tenant:friendly:binky"
|
|
|
|
reactivated = retired.reactivate(at=NOW)
|
|
assert reactivated.lifecycle is TenantLifecycle.ACTIVE
|
|
assert reactivated.reactivated_at == NOW
|
|
assert reactivated.retired_at == NOW # retirement history is not erased
|
|
assert reactivated.version == 3
|
|
|
|
|
|
def test_double_retirement_is_an_invalid_transition() -> None:
|
|
retired = _tenant().retire(at=NOW)
|
|
with pytest.raises(InvalidLifecycleTransitionError):
|
|
retired.retire(at=NOW)
|
|
|
|
|
|
def test_reactivating_an_active_tenant_is_an_invalid_transition() -> None:
|
|
with pytest.raises(InvalidLifecycleTransitionError):
|
|
_tenant().reactivate(at=NOW)
|
|
|
|
|
|
def test_metadata_update_denied_while_retired() -> None:
|
|
retired = _tenant().retire(at=NOW)
|
|
with pytest.raises(InvalidLifecycleTransitionError):
|
|
retired.with_metadata({"display_name": "Binky Ltd"}, at=NOW)
|