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>
489 lines
18 KiB
Python
489 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import UTC, datetime
|
|
|
|
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from tenant_engine import __version__
|
|
from tenant_engine.authz import (
|
|
DefaultDenyWriteAuthorizer,
|
|
FlexAuthWriteAuthorizer,
|
|
WriteAuthorizationDeniedError,
|
|
WriteAuthorizer,
|
|
)
|
|
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,
|
|
)
|
|
|
|
|
|
class CreateTenantRequest(BaseModel):
|
|
tenant_id: str
|
|
identifier: str
|
|
actor: str
|
|
display_name: str | None = None
|
|
contact_email: str | None = None
|
|
|
|
|
|
class GrantRoleRequest(BaseModel):
|
|
grant_id: str
|
|
role: CapabilityRole
|
|
grant_reason: GrantReason
|
|
plan_id: str | None = None
|
|
granted_by: str
|
|
correlation_id: str
|
|
actor: str
|
|
|
|
|
|
class RevokeRoleRequest(BaseModel):
|
|
grant_id: str
|
|
actor: str
|
|
|
|
|
|
class AssignPlanRequest(BaseModel):
|
|
plan_id: str
|
|
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,
|
|
authorizer: WriteAuthorizer | None = None,
|
|
settings: Settings | None = None,
|
|
) -> FastAPI:
|
|
store = store or InMemoryTenantStore()
|
|
settings = settings or Settings.from_env()
|
|
authorizer = authorizer or _build_authorizer(settings)
|
|
|
|
app = FastAPI(title="tenant-engine", version=__version__)
|
|
app.state.store = store
|
|
app.state.authorizer = authorizer
|
|
|
|
@app.exception_handler(WriteAuthorizationDeniedError)
|
|
async def handle_denied(_: Request, exc: WriteAuthorizationDeniedError) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=403,
|
|
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")
|
|
async def cache_read_roles(tenant_id: str) -> dict:
|
|
return _read_roles(store, tenant_id)
|
|
|
|
# -- Live-lookup API (flex-auth, for aal2-class decisions) -----------
|
|
# Same handler as the cache-read path: both fail closed on store
|
|
# unavailability (503, never 200 + []). The distinction between the two
|
|
# routes is operational intent -- key-cape calls this one to source a
|
|
# cached claim at issuance, flex-auth calls it synchronously before
|
|
# authorizing a privileged action -- not payload shape or error handling.
|
|
|
|
@app.get("/tenants/{tenant_id}/roles/live")
|
|
async def live_lookup_roles(tenant_id: str) -> dict:
|
|
return _read_roles(store, tenant_id)
|
|
|
|
# -- Write API (grant/revoke/plan mutation) ---------------------------
|
|
# Every mutation goes through `authorizer.authorize()` first. tenant-engine
|
|
# never self-authorizes; see authz.py.
|
|
|
|
@app.post("/tenants", status_code=201)
|
|
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,
|
|
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
|
|
# 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:
|
|
authorizer.authorize(action="tenant.role.grant", tenant_id=tenant_id, actor=payload.actor)
|
|
try:
|
|
tenant = store.get_tenant(tenant_id)
|
|
grant = create_role_grant(
|
|
tenant=tenant,
|
|
grant_id=payload.grant_id,
|
|
role=payload.role,
|
|
grant_reason=payload.grant_reason,
|
|
plan_id=payload.plan_id,
|
|
granted_by=payload.granted_by,
|
|
correlation_id=payload.correlation_id,
|
|
granted_at=datetime.now(UTC),
|
|
)
|
|
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}
|
|
|
|
@app.post("/tenants/{tenant_id}/roles/revoke")
|
|
async def revoke_role(tenant_id: str, payload: RevokeRoleRequest) -> dict:
|
|
authorizer.authorize(action="tenant.role.revoke", tenant_id=tenant_id, actor=payload.actor)
|
|
try:
|
|
revoked = store.revoke_role(tenant_id=tenant_id, grant_id=payload.grant_id, at=datetime.now(UTC))
|
|
except TenantNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
|
except GrantNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail="grant_not_found") from exc
|
|
except InvalidGrantError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
return {"grant_id": revoked.grant_id, "tenant_id": tenant_id, "revoked": True}
|
|
|
|
@app.post("/tenants/{tenant_id}/plan")
|
|
async def assign_plan(tenant_id: str, payload: AssignPlanRequest) -> dict:
|
|
authorizer.authorize(action="tenant.plan.assign", tenant_id=tenant_id, actor=payload.actor)
|
|
try:
|
|
store.assign_plan(
|
|
PlanAssignment(tenant_id=tenant_id, plan_id=payload.plan_id, assigned_at=datetime.now(UTC))
|
|
)
|
|
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()
|
|
client = FlexAuthCheckClient(
|
|
base_url=settings.flex_auth_base_url,
|
|
timeout_seconds=settings.flex_auth_timeout_seconds,
|
|
)
|
|
return FlexAuthWriteAuthorizer(client=client)
|
|
|
|
|
|
def _read_roles(store: TenantStore, tenant_id: str) -> dict:
|
|
try:
|
|
roles = store.active_roles(tenant_id)
|
|
except TenantNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
|
except StoreUnavailableError as exc:
|
|
# Fail closed, never open: unavailability must not look like "zero
|
|
# roles" to a caller on a privileged-decision path.
|
|
raise HTTPException(status_code=503, detail="tenant_roles_unavailable") from exc
|
|
return {"tenant_id": tenant_id, "roles": sorted(role.value for role in roles)}
|