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()