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