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,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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue