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:
tegwick 2026-08-10 20:00:43 +02:00
parent 7dcccafc03
commit d6fd73bd42
10 changed files with 1625 additions and 28 deletions

View file

@ -1,10 +1,12 @@
from __future__ import annotations
import hashlib
import json
from datetime import UTC, datetime
from fastapi import FastAPI, HTTPException, Request
from fastapi import FastAPI, Header, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field
from tenant_engine import __version__
from tenant_engine.authz import (
@ -16,21 +18,27 @@ from tenant_engine.authz import (
from tenant_engine.config import Settings
from tenant_engine.domain import (
CapabilityRole,
EmptyUpdateError,
GrantReason,
ImmutableFieldError,
InvalidGrantError,
InvalidLifecycleTransitionError,
InvalidTenantIdentifierError,
PlanAssignment,
Tenant,
TenantRetiredError,
create_role_grant,
)
from tenant_engine.flex_auth import FlexAuthCheckClient
from tenant_engine.store import (
GrantNotFoundError,
IdempotencyConflictError,
InMemoryTenantStore,
StoreUnavailableError,
TenantAlreadyExistsError,
TenantNotFoundError,
TenantStore,
VersionConflictError,
)
@ -38,6 +46,8 @@ class CreateTenantRequest(BaseModel):
tenant_id: str
identifier: str
actor: str
display_name: str | None = None
contact_email: str | None = None
class GrantRoleRequest(BaseModel):
@ -60,6 +70,53 @@ class AssignPlanRequest(BaseModel):
actor: str
class TenantMetadata(BaseModel):
"""The allow-list, expressed in the schema itself.
`extra="forbid"` is what turns "unknown fields fail closed" into a
contract consumers can see in the OpenAPI document rather than a rule
they discover from a 400.
"""
model_config = {"extra": "forbid"}
display_name: str | None = None
contact_email: str | None = None
class UpdateTenantRequest(BaseModel):
model_config = {"extra": "forbid"}
metadata: TenantMetadata
actor: str
reason: str = Field(min_length=1)
correlation_id: str = Field(min_length=1)
class LifecycleRequest(BaseModel):
model_config = {"extra": "forbid"}
actor: str
reason: str = Field(min_length=1)
correlation_id: str = Field(min_length=1)
class LifecycleError(Exception):
"""A lifecycle-endpoint failure rendered in the stable error schema.
Carries only strings this service chose. Store and policy exceptions are
mapped to one of these at the boundary, never reflected verbatim -- a 503
must not leak a database path or a flex-auth policy name to a consumer.
"""
def __init__(self, status_code: int, error_code: str, detail: str, correlation_id: str) -> None:
super().__init__(f"{status_code} {error_code}")
self.status_code = status_code
self.error_code = error_code
self.detail = detail
self.correlation_id = correlation_id
def create_app(
*,
store: TenantStore | None = None,
@ -81,10 +138,38 @@ def create_app(
content={"error_code": "write_denied", "action": exc.action, "detail": exc.reason},
)
@app.exception_handler(LifecycleError)
async def handle_lifecycle_error(_: Request, exc: LifecycleError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={
"error_code": exc.error_code,
"detail": exc.detail,
"correlation_id": exc.correlation_id,
},
)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "service": "tenant-engine", "version": __version__}
# -- Authoritative tenant record read (TEN-WP-0005) -------------------
# user-engine's platform operator UI reads this before offering an edit,
# and echoes the returned ETag back as If-Match on the mutation.
@app.get("/tenants/{tenant_id}")
async def get_tenant(tenant_id: str, response: Response) -> dict:
try:
tenant = store.get_tenant(tenant_id)
except TenantNotFoundError as exc:
raise LifecycleError(404, "tenant_not_found", "tenant_not_found", "") from exc
except StoreUnavailableError as exc:
raise LifecycleError(
503, "tenant_authority_unavailable", "tenant_authority_unavailable", ""
) from exc
response.headers["ETag"] = _etag(tenant.version)
return _tenant_response(tenant)
# -- Cache-read API (key-cape, at token issuance) --------------------
@app.get("/tenants/{tenant_id}/roles")
@ -110,13 +195,21 @@ def create_app(
async def create_tenant(payload: CreateTenantRequest) -> dict:
authorizer.authorize(action="tenant.create", tenant_id=payload.tenant_id, actor=payload.actor)
try:
tenant = Tenant.create(tenant_id=payload.tenant_id, identifier=payload.identifier)
tenant = Tenant.create(
tenant_id=payload.tenant_id,
identifier=payload.identifier,
display_name=payload.display_name,
contact_email=payload.contact_email,
created_at=datetime.now(UTC),
)
store.create_tenant(tenant)
except InvalidTenantIdentifierError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except TenantAlreadyExistsError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"tenant_id": tenant.tenant_id, "identifier": tenant.identifier, "grouping": tenant.grouping}
# Superset of the pre-lifecycle response (tenant_id/identifier/grouping
# are unchanged), so existing create clients keep working.
return _tenant_response(tenant)
@app.post("/tenants/{tenant_id}/roles/grant", status_code=201)
async def grant_role(tenant_id: str, payload: GrantRoleRequest) -> dict:
@ -136,6 +229,8 @@ def create_app(
store.grant_role(grant)
except TenantNotFoundError as exc:
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
except TenantRetiredError as exc:
raise HTTPException(status_code=409, detail="tenant_retired") from exc
except InvalidGrantError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"grant_id": grant.grant_id, "tenant_id": tenant_id, "role": grant.role.value}
@ -162,11 +257,216 @@ def create_app(
)
except TenantNotFoundError as exc:
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
except TenantRetiredError as exc:
raise HTTPException(status_code=409, detail="tenant_retired") from exc
return {"tenant_id": tenant_id, "plan_id": payload.plan_id}
# -- Lifecycle write API (TEN-WP-0005) --------------------------------
# Update, retire, and reactivate are separately authorized actions, not
# one "tenant.write": user-engine's operator UI can be permitted to
# rename a tenant without thereby being permitted to retire it.
@app.patch("/tenants/{tenant_id}")
async def update_tenant(
tenant_id: str,
payload: UpdateTenantRequest,
response: Response,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict:
changes = payload.metadata.model_dump(exclude_unset=True)
return _lifecycle_mutation(
store=store,
authorizer=authorizer,
action="tenant.update",
tenant_id=tenant_id,
actor=payload.actor,
reason=payload.reason,
correlation_id=payload.correlation_id,
idempotency_key=idempotency_key,
if_match=if_match,
response=response,
event_type="tenant_updated",
extra_fingerprint=changes,
mutate=lambda tenant, at: tenant.with_metadata(changes, at=at),
)
@app.post("/tenants/{tenant_id}/retire")
async def retire_tenant(
tenant_id: str,
payload: LifecycleRequest,
response: Response,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict:
return _lifecycle_mutation(
store=store,
authorizer=authorizer,
action="tenant.retire",
tenant_id=tenant_id,
actor=payload.actor,
reason=payload.reason,
correlation_id=payload.correlation_id,
idempotency_key=idempotency_key,
if_match=if_match,
response=response,
event_type="tenant_retired",
extra_fingerprint={},
mutate=lambda tenant, at: tenant.retire(at=at),
)
@app.post("/tenants/{tenant_id}/reactivate")
async def reactivate_tenant(
tenant_id: str,
payload: LifecycleRequest,
response: Response,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict:
return _lifecycle_mutation(
store=store,
authorizer=authorizer,
action="tenant.reactivate",
tenant_id=tenant_id,
actor=payload.actor,
reason=payload.reason,
correlation_id=payload.correlation_id,
idempotency_key=idempotency_key,
if_match=if_match,
response=response,
event_type="tenant_reactivated",
extra_fingerprint={},
mutate=lambda tenant, at: tenant.reactivate(at=at),
)
return app
def _etag(version: int) -> str:
return f'"{version}"'
def _parse_if_match(if_match: str | None, correlation_id: str) -> int:
if if_match is None:
raise LifecycleError(
428, "if_match_required", "If-Match header is required", correlation_id
)
candidate = if_match.strip()
if candidate.startswith("W/"):
candidate = candidate[2:]
candidate = candidate.strip('"')
if candidate == "*" or not candidate.isdigit():
# `*` would mean "whatever version is current", which is precisely
# the unconditional write this endpoint exists to prevent.
raise LifecycleError(
400, "invalid_if_match", "If-Match must be a record version ETag", correlation_id
)
return int(candidate)
def _tenant_response(tenant: Tenant) -> dict:
return {
"tenant_id": tenant.tenant_id,
"identifier": tenant.identifier,
"grouping": tenant.grouping,
"display_name": tenant.display_name,
"contact_email": tenant.contact_email,
"lifecycle": tenant.lifecycle.value,
"version": tenant.version,
"created_at": _iso(tenant.created_at),
"updated_at": _iso(tenant.updated_at),
"retired_at": _iso(tenant.retired_at),
"reactivated_at": _iso(tenant.reactivated_at),
}
def _iso(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _lifecycle_mutation(
*,
store: TenantStore,
authorizer: WriteAuthorizer,
action: str,
tenant_id: str,
actor: str,
reason: str,
correlation_id: str,
idempotency_key: str | None,
if_match: str | None,
response: Response,
event_type: str,
extra_fingerprint: dict,
mutate,
) -> dict:
"""Shared spine for update/retire/reactivate: authorize, then CAS.
Authorization runs before the store is touched at all, so an unauthorized
caller cannot use timing or error codes to probe which tenants exist.
"""
if idempotency_key is None:
raise LifecycleError(
400, "idempotency_key_required", "Idempotency-Key header is required", correlation_id
)
expected_version = _parse_if_match(if_match, correlation_id)
authorizer.authorize(action=action, tenant_id=tenant_id, actor=actor)
fingerprint = hashlib.sha256(
json.dumps(
{
"action": action,
"tenant": tenant_id,
"version": expected_version,
**extra_fingerprint,
},
sort_keys=True,
default=str,
).encode()
).hexdigest()
now = datetime.now(UTC)
try:
tenant, replayed = store.mutate_tenant(
tenant_id=tenant_id,
expected_version=expected_version,
mutate=lambda current: mutate(current, now),
event_type=event_type,
evidence={"actor": actor, "reason": reason, "correlation_id": correlation_id},
idempotency_key=idempotency_key,
request_fingerprint=fingerprint,
)
except TenantNotFoundError as exc:
raise LifecycleError(404, "tenant_not_found", "tenant_not_found", correlation_id) from exc
except IdempotencyConflictError as exc:
raise LifecycleError(
409,
"idempotency_key_conflict",
"Idempotency-Key was reused for a different request",
correlation_id,
) from exc
except VersionConflictError as exc:
raise LifecycleError(
409,
"version_conflict",
f"record version is {exc.actual}, not {exc.expected}",
correlation_id,
) from exc
except (InvalidLifecycleTransitionError, TenantRetiredError) as exc:
raise LifecycleError(409, "invalid_lifecycle_transition", str(exc), correlation_id) from exc
except (ImmutableFieldError, EmptyUpdateError) as exc:
raise LifecycleError(400, "invalid_update", str(exc), correlation_id) from exc
except StoreUnavailableError as exc:
raise LifecycleError(
503, "tenant_authority_unavailable", "tenant_authority_unavailable", correlation_id
) from exc
response.headers["ETag"] = _etag(tenant.version)
response.headers["Idempotent-Replay"] = "true" if replayed else "false"
return _tenant_response(tenant)
def _build_authorizer(settings: Settings) -> WriteAuthorizer:
if settings.flex_auth_base_url is None:
return DefaultDenyWriteAuthorizer()

View file

@ -12,6 +12,11 @@ _RESOURCE_TYPES: dict[str, str] = {
"tenant.role.grant": "role-grant",
"tenant.role.revoke": "role-grant",
"tenant.plan.assign": "plan-assignment",
# TEN-WP-0005: lifecycle actions are distinct so policy can separate a
# metadata edit from a retirement.
"tenant.update": "tenant",
"tenant.retire": "tenant",
"tenant.reactivate": "tenant",
}

View file

@ -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:

View file

@ -2,15 +2,26 @@ from __future__ import annotations
import json
import sqlite3
from collections.abc import Callable
from datetime import datetime
from threading import RLock
from typing import Any
from tenant_engine.domain import CapabilityRole, PlanAssignment, RoleGrant, Tenant
from tenant_engine.domain import (
CapabilityRole,
PlanAssignment,
RoleGrant,
Tenant,
TenantLifecycle,
TenantRetiredError,
)
from tenant_engine.store import (
DomainEvent,
GrantNotFoundError,
IdempotencyConflictError,
TenantAlreadyExistsError,
TenantNotFoundError,
VersionConflictError,
)
@ -40,14 +51,50 @@ class SQLiteTenantStore:
seq INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL,
tenant_id TEXT NOT NULL, at TEXT NOT NULL, payload TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS idempotency_receipts (
tenant_id TEXT NOT NULL, idempotency_key TEXT NOT NULL,
request_fingerprint TEXT NOT NULL, result TEXT NOT NULL,
recorded_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, idempotency_key)
);
""")
self._migrate_tenant_lifecycle()
def _migrate_tenant_lifecycle(self) -> None:
"""TEN-WP-0005-T02: forward-only, idempotent lifecycle migration.
Existing rows default to `active` at version 1 with their identifiers,
grants, and plan assignments untouched -- nothing is rewritten, only
columns are added. Fresh and pre-existing databases take this same
path, so there is no second schema definition to drift out of sync.
"""
existing = {row["name"] for row in self._db.execute("PRAGMA table_info(tenants)")}
additions = {
"display_name": "TEXT",
"contact_email": "TEXT",
"lifecycle": "TEXT NOT NULL DEFAULT 'active'",
"version": "INTEGER NOT NULL DEFAULT 1",
"created_at": "TEXT",
"updated_at": "TEXT",
"retired_at": "TEXT",
"reactivated_at": "TEXT",
}
for column, spec in additions.items():
if column not in existing:
self._db.execute(f"ALTER TABLE tenants ADD COLUMN {column} {spec}")
def create_tenant(self, tenant: Tenant) -> None:
with self._lock, self._db:
try:
self._db.execute(
"INSERT INTO tenants VALUES (?, ?, ?)",
(tenant.tenant_id, tenant.identifier, tenant.grouping),
"""INSERT INTO tenants (tenant_id, identifier, grouping_name, display_name,
contact_email, lifecycle, version, created_at, updated_at,
retired_at, reactivated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(tenant.tenant_id, tenant.identifier, tenant.grouping, tenant.display_name,
tenant.contact_email, tenant.lifecycle.value, tenant.version,
_iso(tenant.created_at), _iso(tenant.updated_at),
_iso(tenant.retired_at), _iso(tenant.reactivated_at)),
)
except sqlite3.IntegrityError as exc:
raise TenantAlreadyExistsError(tenant.identifier) from exc
@ -56,15 +103,83 @@ class SQLiteTenantStore:
})
def get_tenant(self, tenant_id: str) -> Tenant:
row = self._db.execute(
"SELECT * FROM tenants WHERE tenant_id = ? OR identifier = ?", (tenant_id, tenant_id)
).fetchone()
# Reads take the same lock as writes. One sqlite3 connection is shared
# across request threads (check_same_thread=False), so an unguarded
# SELECT can execute while another thread sits inside BEGIN IMMEDIATE
# and observe a row that is not there yet -- caught by the concurrent
# writer test, which failed with a spurious tenant_not_found.
with self._lock:
row = self._db.execute(
"SELECT * FROM tenants WHERE tenant_id = ? OR identifier = ?",
(tenant_id, tenant_id),
).fetchone()
if row is None:
raise TenantNotFoundError(tenant_id)
return Tenant(row["tenant_id"], row["identifier"], row["grouping_name"])
return _tenant(row)
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]:
tenant = self.get_tenant(tenant_id)
with self._lock:
# BEGIN IMMEDIATE takes the write lock before the read below, so a
# concurrent writer cannot slip a version bump between the check
# and the swap. Receipt lookup precedes the version check: a real
# retry replays the original If-Match, which is stale by then.
self._db.execute("BEGIN IMMEDIATE")
try:
receipt = self._db.execute(
"SELECT request_fingerprint, result FROM idempotency_receipts "
"WHERE tenant_id = ? AND idempotency_key = ?",
(tenant.tenant_id, idempotency_key),
).fetchone()
if receipt is not None:
if receipt["request_fingerprint"] != request_fingerprint:
raise IdempotencyConflictError(idempotency_key)
replayed = _tenant(json.loads(receipt["result"]))
self._db.rollback() # read-only path: release the write lock
return replayed, True
current = _tenant(
self._db.execute(
"SELECT * FROM tenants WHERE tenant_id = ?", (tenant.tenant_id,)
).fetchone()
)
if current.version != expected_version:
raise VersionConflictError(expected=expected_version, actual=current.version)
updated = mutate(current)
self._db.execute(
"""UPDATE tenants SET display_name = ?, contact_email = ?, lifecycle = ?,
version = ?, updated_at = ?, retired_at = ?, reactivated_at = ?
WHERE tenant_id = ?""",
(updated.display_name, updated.contact_email, updated.lifecycle.value,
updated.version, _iso(updated.updated_at), _iso(updated.retired_at),
_iso(updated.reactivated_at), updated.tenant_id),
)
self._db.execute(
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
(updated.tenant_id, idempotency_key, request_fingerprint,
json.dumps(_row(updated)), datetime.now().astimezone().isoformat()),
)
self._emit(event_type, updated.tenant_id, {**evidence, "version": updated.version})
except BaseException:
self._db.rollback()
raise
self._db.commit()
return updated, False
def grant_role(self, grant: RoleGrant) -> None:
tenant = self.get_tenant(grant.tenant_id)
self._require_active(tenant, "grant a role")
with self._lock, self._db:
self._db.execute(
"INSERT OR REPLACE INTO grants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
@ -78,10 +193,11 @@ class SQLiteTenantStore:
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
tenant = self.get_tenant(tenant_id)
row = self._db.execute(
"SELECT * FROM grants WHERE tenant_id = ? AND grant_id = ?",
(tenant.tenant_id, grant_id),
).fetchone()
with self._lock:
row = self._db.execute(
"SELECT * FROM grants WHERE tenant_id = ? AND grant_id = ?",
(tenant.tenant_id, grant_id),
).fetchone()
if row is None:
raise GrantNotFoundError(grant_id)
grant = self._grant(row).revoke(at=at)
@ -94,14 +210,16 @@ class SQLiteTenantStore:
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]:
tenant = self.get_tenant(tenant_id)
rows = self._db.execute(
"SELECT role FROM grants WHERE tenant_id = ? AND revoked_at IS NULL",
(tenant.tenant_id,),
).fetchall()
with self._lock:
rows = self._db.execute(
"SELECT role FROM grants WHERE tenant_id = ? AND revoked_at IS NULL",
(tenant.tenant_id,),
).fetchall()
return frozenset(CapabilityRole(row["role"]) for row in rows)
def assign_plan(self, assignment: PlanAssignment) -> None:
tenant = self.get_tenant(assignment.tenant_id)
self._require_active(tenant, "assign a plan")
with self._lock, self._db:
self._db.execute(
"INSERT OR REPLACE INTO plans VALUES (?, ?, ?)",
@ -110,9 +228,16 @@ class SQLiteTenantStore:
self._emit("plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id})
def events(self) -> list[DomainEvent]:
with self._lock:
rows = self._db.execute("SELECT * FROM events ORDER BY seq").fetchall()
return [DomainEvent(row["event_type"], row["tenant_id"],
datetime.fromisoformat(row["at"]), json.loads(row["payload"]))
for row in self._db.execute("SELECT * FROM events ORDER BY seq")]
for row in rows]
@staticmethod
def _require_active(tenant: Tenant, what: str) -> None:
if tenant.lifecycle is not TenantLifecycle.ACTIVE:
raise TenantRetiredError(f"cannot {what} on a retired tenant")
def _emit(self, event_type: str, tenant_id: str, payload: dict) -> None:
now = datetime.now().astimezone()
@ -125,3 +250,45 @@ class SQLiteTenantStore:
row["grant_reason"], row["plan_id"], row["granted_by"],
datetime.fromisoformat(row["granted_at"]), row["correlation_id"],
datetime.fromisoformat(row["revoked_at"]) if row["revoked_at"] else None)
def _iso(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _dt(value: str | None) -> datetime | None:
return datetime.fromisoformat(value) if value else None
def _tenant(row: sqlite3.Row | dict) -> Tenant:
"""Map a tenants row -- or a receipt's JSON snapshot -- to a Tenant."""
return Tenant(
tenant_id=row["tenant_id"],
identifier=row["identifier"],
grouping=row["grouping_name"],
display_name=row["display_name"],
contact_email=row["contact_email"],
lifecycle=TenantLifecycle(row["lifecycle"]),
version=row["version"],
created_at=_dt(row["created_at"]),
updated_at=_dt(row["updated_at"]),
retired_at=_dt(row["retired_at"]),
reactivated_at=_dt(row["reactivated_at"]),
)
def _row(tenant: Tenant) -> dict:
"""Inverse of `_tenant`, for durable idempotency receipts."""
return {
"tenant_id": tenant.tenant_id,
"identifier": tenant.identifier,
"grouping_name": tenant.grouping,
"display_name": tenant.display_name,
"contact_email": tenant.contact_email,
"lifecycle": tenant.lifecycle.value,
"version": tenant.version,
"created_at": _iso(tenant.created_at),
"updated_at": _iso(tenant.updated_at),
"retired_at": _iso(tenant.retired_at),
"reactivated_at": _iso(tenant.reactivated_at),
}

View file

@ -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