Implement tenant update and reversible retirement API (TEN-WP-0005 T01-T04)
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>
This commit is contained in:
parent
7dcccafc03
commit
d6fd73bd42
10 changed files with 1625 additions and 28 deletions
88
tests/test_lifecycle_domain.py
Normal file
88
tests/test_lifecycle_domain.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue