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
|
|
@ -1,16 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from tenant_engine.domain import CapabilityRole, PlanAssignment, RoleGrant, Tenant
|
||||
from tenant_engine.domain import (
|
||||
CapabilityRole,
|
||||
PlanAssignment,
|
||||
RoleGrant,
|
||||
Tenant,
|
||||
TenantLifecycle,
|
||||
TenantRetiredError,
|
||||
)
|
||||
|
||||
|
||||
class TenantNotFoundError(KeyError):
|
||||
pass
|
||||
|
||||
|
||||
class VersionConflictError(RuntimeError):
|
||||
"""The caller's `If-Match` version is not the tenant's current version."""
|
||||
|
||||
def __init__(self, *, expected: int, actual: int) -> None:
|
||||
super().__init__(f"expected version {expected}, current version is {actual}")
|
||||
self.expected = expected
|
||||
self.actual = actual
|
||||
|
||||
|
||||
class IdempotencyConflictError(RuntimeError):
|
||||
"""An `Idempotency-Key` was reused for a materially different request."""
|
||||
|
||||
|
||||
class TenantAlreadyExistsError(ValueError):
|
||||
pass
|
||||
|
||||
|
|
@ -63,6 +84,31 @@ class TenantStore(Protocol):
|
|||
|
||||
def events(self) -> list[DomainEvent]: ...
|
||||
|
||||
def mutate_tenant(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
expected_version: int,
|
||||
mutate: Callable[[Tenant], Tenant],
|
||||
event_type: str,
|
||||
evidence: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
request_fingerprint: str,
|
||||
) -> tuple[Tenant, bool]:
|
||||
"""Atomically compare-and-swap a tenant record. Returns (tenant, replayed).
|
||||
|
||||
One method carries all four concerns -- idempotency replay, version
|
||||
CAS, the mutation itself, and the audit event -- because they have to
|
||||
commit or fail together. Splitting them across store calls would leave
|
||||
a window where a crash yields a bumped version with no receipt (a
|
||||
retry then double-applies) or a receipt with no mutation.
|
||||
|
||||
Order matters: a replayed `idempotency_key` short-circuits *before*
|
||||
the version check, because a genuine retry of an already-applied
|
||||
mutation necessarily carries a now-stale `If-Match`.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryTenantStore:
|
||||
def __init__(self) -> None:
|
||||
|
|
@ -71,6 +117,8 @@ class InMemoryTenantStore:
|
|||
self._grants: dict[str, dict[str, RoleGrant]] = {}
|
||||
self._plans: dict[str, PlanAssignment] = {}
|
||||
self._events: list[DomainEvent] = []
|
||||
# (tenant_id, idempotency_key) -> (request_fingerprint, result snapshot)
|
||||
self._receipts: dict[tuple[str, str], tuple[str, Tenant]] = {}
|
||||
|
||||
def create_tenant(self, tenant: Tenant) -> None:
|
||||
if tenant.tenant_id in self._tenants:
|
||||
|
|
@ -91,6 +139,7 @@ class InMemoryTenantStore:
|
|||
|
||||
def grant_role(self, grant: RoleGrant) -> None:
|
||||
resolved = self._resolve(grant.tenant_id)
|
||||
self._require_active(resolved, "grant a role")
|
||||
self._grants[resolved][grant.grant_id] = grant
|
||||
self._emit(
|
||||
"role_granted",
|
||||
|
|
@ -126,12 +175,48 @@ class InMemoryTenantStore:
|
|||
|
||||
def assign_plan(self, assignment: PlanAssignment) -> None:
|
||||
resolved = self._resolve(assignment.tenant_id)
|
||||
self._require_active(resolved, "assign a plan")
|
||||
self._plans[resolved] = assignment
|
||||
self._emit("plan_assigned", resolved, {"plan_id": assignment.plan_id})
|
||||
|
||||
def events(self) -> list[DomainEvent]:
|
||||
return list(self._events)
|
||||
|
||||
def mutate_tenant(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
expected_version: int,
|
||||
mutate: Callable[[Tenant], Tenant],
|
||||
event_type: str,
|
||||
evidence: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
request_fingerprint: str,
|
||||
) -> tuple[Tenant, bool]:
|
||||
resolved = self._resolve(tenant_id)
|
||||
|
||||
receipt = self._receipts.get((resolved, idempotency_key))
|
||||
if receipt is not None:
|
||||
fingerprint, snapshot = receipt
|
||||
if fingerprint != request_fingerprint:
|
||||
raise IdempotencyConflictError(idempotency_key)
|
||||
return snapshot, True
|
||||
|
||||
current = self._tenants[resolved]
|
||||
if current.version != expected_version:
|
||||
raise VersionConflictError(expected=expected_version, actual=current.version)
|
||||
|
||||
updated = mutate(current)
|
||||
self._tenants[resolved] = updated
|
||||
self._receipts[(resolved, idempotency_key)] = (request_fingerprint, updated)
|
||||
self._emit(event_type, resolved, {**evidence, "version": updated.version})
|
||||
return updated, False
|
||||
|
||||
def _require_active(self, resolved_id: str, what: str) -> None:
|
||||
tenant = self._tenants[resolved_id]
|
||||
if tenant.lifecycle is not TenantLifecycle.ACTIVE:
|
||||
raise TenantRetiredError(f"cannot {what} on a retired tenant")
|
||||
|
||||
def _resolve(self, tenant_id: str) -> str:
|
||||
"""Resolve an internal tenant_id or a profile identifier to the
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue