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
|
|
@ -38,6 +38,41 @@ class InvalidGrantError(ValueError):
|
|||
"""A role grant violates a domain invariant (ADR-0014)."""
|
||||
|
||||
|
||||
class InvalidLifecycleTransitionError(ValueError):
|
||||
"""A lifecycle transition is not legal from the tenant's current state."""
|
||||
|
||||
|
||||
class ImmutableFieldError(ValueError):
|
||||
"""An update tried to change a field that is immutable by contract."""
|
||||
|
||||
|
||||
class EmptyUpdateError(ValueError):
|
||||
"""An update carried no allow-listed field changes."""
|
||||
|
||||
|
||||
class TenantRetiredError(ValueError):
|
||||
"""A mutation was attempted on a retired tenant that only active tenants allow."""
|
||||
|
||||
|
||||
class TenantLifecycle(str, Enum):
|
||||
"""TEN-WP-0005: tenant existence is reversible, never hard-deleted.
|
||||
|
||||
Retirement suspends a tenant's ability to take on new capability or plan
|
||||
state; it deliberately preserves the tenant record, its grant history,
|
||||
and its plan history so audit correlation and recovery stay intact.
|
||||
"""
|
||||
|
||||
ACTIVE = "active"
|
||||
RETIRED = "retired"
|
||||
|
||||
|
||||
# The only tenant fields a PATCH may change. tenant_id, identifier, and
|
||||
# grouping are immutable: the identifier is the IAM Profile `tenant` claim
|
||||
# value that key-cape mints into tokens and flex-auth authorizes against, so
|
||||
# mutating it would silently invalidate every issued token referencing it.
|
||||
MUTABLE_METADATA_FIELDS = frozenset({"display_name", "contact_email"})
|
||||
|
||||
|
||||
class CapabilityRole(str, Enum):
|
||||
"""ADR-0014: non-exclusive capability roles a tenant may hold."""
|
||||
|
||||
|
|
@ -73,16 +108,99 @@ class Tenant:
|
|||
tenant_id: str
|
||||
identifier: str
|
||||
grouping: str | None
|
||||
# -- TEN-WP-0005 lifecycle and mutable metadata. All default so that
|
||||
# pre-lifecycle construction sites (and migrated rows) keep working.
|
||||
display_name: str | None = None
|
||||
contact_email: str | None = None
|
||||
lifecycle: TenantLifecycle = TenantLifecycle.ACTIVE
|
||||
version: int = 1
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
retired_at: datetime | None = None
|
||||
reactivated_at: datetime | None = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, *, tenant_id: str, identifier: str) -> "Tenant":
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
identifier: str,
|
||||
display_name: str | None = None,
|
||||
contact_email: str | None = None,
|
||||
created_at: datetime | None = None,
|
||||
) -> "Tenant":
|
||||
grouping, _name = parse_tenant_identifier(identifier)
|
||||
return cls(tenant_id=tenant_id, identifier=identifier, grouping=grouping)
|
||||
return cls(
|
||||
tenant_id=tenant_id,
|
||||
identifier=identifier,
|
||||
grouping=grouping,
|
||||
display_name=display_name,
|
||||
contact_email=contact_email,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_reserved(self) -> bool:
|
||||
return self.grouping is None
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.lifecycle is TenantLifecycle.ACTIVE
|
||||
|
||||
def with_metadata(self, changes: dict[str, object], *, at: datetime) -> "Tenant":
|
||||
"""Apply an allow-listed metadata change, bumping the record version.
|
||||
|
||||
Fails closed on anything ambiguous: unknown fields, attempts to change
|
||||
an immutable field, an empty change set, or a no-op change set. A no-op
|
||||
is rejected rather than silently accepted so a caller never reads a
|
||||
version bump as evidence that a value actually changed.
|
||||
"""
|
||||
if self.lifecycle is not TenantLifecycle.ACTIVE:
|
||||
raise InvalidLifecycleTransitionError(
|
||||
"metadata of a retired tenant cannot be updated; reactivate first"
|
||||
)
|
||||
|
||||
unknown = set(changes) - MUTABLE_METADATA_FIELDS
|
||||
immutable = unknown & {"tenant_id", "identifier", "grouping", "version", "lifecycle"}
|
||||
if immutable:
|
||||
raise ImmutableFieldError(f"immutable field(s): {', '.join(sorted(immutable))}")
|
||||
if unknown:
|
||||
raise ImmutableFieldError(f"unknown field(s): {', '.join(sorted(unknown))}")
|
||||
if not changes:
|
||||
raise EmptyUpdateError("update carried no fields")
|
||||
if all(getattr(self, field) == value for field, value in changes.items()):
|
||||
raise EmptyUpdateError("update would not change any field")
|
||||
|
||||
return replace(self, version=self.version + 1, updated_at=at, **changes) # type: ignore[arg-type]
|
||||
|
||||
def retire(self, *, at: datetime) -> "Tenant":
|
||||
if self.lifecycle is TenantLifecycle.RETIRED:
|
||||
raise InvalidLifecycleTransitionError("tenant is already retired")
|
||||
return replace(
|
||||
self,
|
||||
lifecycle=TenantLifecycle.RETIRED,
|
||||
version=self.version + 1,
|
||||
updated_at=at,
|
||||
retired_at=at,
|
||||
)
|
||||
|
||||
def reactivate(self, *, at: datetime) -> "Tenant":
|
||||
"""Return the tenant to active. Deliberately narrow: it restores the
|
||||
tenant's ability to receive new grants and plan changes, and does not
|
||||
resurrect revoked grants or invent plan state -- those stay exactly as
|
||||
retirement left them.
|
||||
"""
|
||||
if self.lifecycle is TenantLifecycle.ACTIVE:
|
||||
raise InvalidLifecycleTransitionError("tenant is already active")
|
||||
return replace(
|
||||
self,
|
||||
lifecycle=TenantLifecycle.ACTIVE,
|
||||
version=self.version + 1,
|
||||
updated_at=at,
|
||||
reactivated_at=at,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoleGrant:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue