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.guardrail import ( ConflictingLimitError, EffectiveLimit, InvalidLimitError, LimitKind, LimitValue, UnknownLimitKeyError, dump_limit, load_limit, resolve_limit, resolve_limits, ) from tenant_engine.guardrail.serde import UNLIMITED_TOKEN 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 SetGroupingRequest(BaseModel): model_config = {"extra": "forbid"} grouping: str = Field(min_length=1) actor: str reason: str = Field(min_length=1) correlation_id: str = Field(min_length=1) class GuardrailLimitBody(BaseModel): """A ceiling, as a consumer states it. `amount` is a string so the `unlimited` sentinel and an integer share one field without a union that JSON would blur -- and so a spend amount in minor units never round-trips through a float. """ model_config = {"extra": "forbid"} kind: LimitKind amount: str = Field(min_length=1) currency: str | None = None period: str | None = None class SetGuardrailRequest(BaseModel): model_config = {"extra": "forbid"} limit: GuardrailLimitBody actor: str reason: str = Field(min_length=1) correlation_id: str = Field(min_length=1) class ClearGuardrailRequest(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), ) # -- Reclassification (TEN-WP-0010) ----------------------------------- # Separate from PATCH /tenants/{id} on purpose: grouping resolves spend # ceilings, so policy must be able to permit a rename without permitting a # reclassification, and the audit trail must show which one happened. @app.post("/tenants/{tenant_id}/grouping") async def set_grouping( tenant_id: str, payload: SetGroupingRequest, 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.grouping.set", 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_grouping_changed", extra_fingerprint={"grouping": payload.grouping}, mutate=lambda tenant, at: tenant.with_grouping(payload.grouping, at=at), ) # -- Guardrail API (TEN-WP-0006) -------------------------------------- # Reading a ceiling and changing one are distinct actions, so policy can # give flex-auth the read without giving anything the write. @app.get("/tenants/{tenant_id}/guardrails") async def read_guardrails(tenant_id: str, actor: str) -> dict: authorizer.authorize(action="tenant.guardrail.read", tenant_id=tenant_id, actor=actor) try: tenant = store.get_tenant(tenant_id) overrides = store.guardrail_overrides(tenant_id) except TenantNotFoundError as exc: raise LifecycleError(404, "tenant_not_found", "tenant_not_found", "") from exc except StoreUnavailableError as exc: # Fail closed: a PDP must never read unavailability as "no limits". raise LifecycleError( 503, "tenant_authority_unavailable", "tenant_authority_unavailable", "" ) from exc # plan_limits is empty until adaptive-pricing exposes plan-derived # ceilings. The precedence layer exists and is tested; the feed does # not, and inventing one here would duplicate plan terms this repo # does not own. resolved = resolve_limits(tenant=tenant, overrides=overrides) return { "tenant_id": tenant.tenant_id, "identifier": tenant.identifier, "lifecycle": tenant.lifecycle.value, "limits": {key: _limit_response(limit) for key, limit in resolved.items()}, } @app.put("/tenants/{tenant_id}/guardrails/{limit_key}") async def set_guardrail( tenant_id: str, limit_key: str, payload: SetGuardrailRequest, response: Response, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), if_match: str | None = Header(default=None, alias="If-Match"), ) -> dict: try: value = load_limit(payload.limit.model_dump()) except (InvalidLimitError, ValueError) as exc: raise LifecycleError( 400, "invalid_limit", str(exc), payload.correlation_id ) from exc return _guardrail_mutation( store=store, authorizer=authorizer, tenant_id=tenant_id, limit_key=limit_key, value=value, actor=payload.actor, reason=payload.reason, correlation_id=payload.correlation_id, idempotency_key=idempotency_key, if_match=if_match, response=response, ) @app.delete("/tenants/{tenant_id}/guardrails/{limit_key}") async def clear_guardrail( tenant_id: str, limit_key: str, payload: ClearGuardrailRequest, response: Response, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), if_match: str | None = Header(default=None, alias="If-Match"), ) -> dict: return _guardrail_mutation( store=store, authorizer=authorizer, tenant_id=tenant_id, limit_key=limit_key, value=None, actor=payload.actor, reason=payload.reason, correlation_id=payload.correlation_id, idempotency_key=idempotency_key, if_match=if_match, response=response, ) return app def _limit_response(limit: EffectiveLimit) -> dict: return { "kind": limit.value.kind.value, "amount": UNLIMITED_TOKEN if limit.value.is_unlimited else limit.value.amount, "currency": limit.value.currency, "period": limit.value.period, "provenance": limit.provenance.value, } def _guardrail_mutation( *, store: TenantStore, authorizer: WriteAuthorizer, tenant_id: str, limit_key: str, value: LimitValue | None, actor: str, reason: str, correlation_id: str, idempotency_key: str | None, if_match: str | None, response: Response, ) -> dict: """Shared spine for setting and clearing an override. Mirrors `_lifecycle_mutation`: header checks, then authorization, then the store. Authorization runs before the store is touched, so an unauthorized caller cannot probe which tenants exist or which keys are registered. """ 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="tenant.guardrail.set", tenant_id=tenant_id, actor=actor) fingerprint = hashlib.sha256( json.dumps( { "action": "tenant.guardrail.set", "tenant": tenant_id, "limit_key": limit_key, "version": expected_version, "value": dump_limit(value) if value is not None else None, }, sort_keys=True, default=str, ).encode() ).hexdigest() # Derived, not random: a genuine retry must produce the same change_id so # the replay path returns the original audit record rather than minting a # second one for a mutation that happened once. change_id = hashlib.sha256( f"{tenant_id}:{limit_key}:{idempotency_key}".encode() ).hexdigest()[:32] try: tenant, change, replayed = store.set_guardrail_override( tenant_id=tenant_id, expected_version=expected_version, limit_key=limit_key, value=value, change_id=change_id, changed_by=actor, reason=reason, correlation_id=correlation_id, idempotency_key=idempotency_key, request_fingerprint=fingerprint, at=datetime.now(UTC), ) except UnknownLimitKeyError as exc: raise LifecycleError(404, "unknown_limit_key", "unknown_limit_key", correlation_id) from exc 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 TenantRetiredError as exc: raise LifecycleError( 409, "guardrail_loosening_denied", str(exc), correlation_id ) from exc except (InvalidLimitError, ConflictingLimitError) as exc: raise LifecycleError(400, "invalid_limit", 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" effective = resolve_limit( limit_key, tenant=tenant, overrides=store.guardrail_overrides(tenant.tenant_id) ) return { "tenant_id": tenant.tenant_id, "limit_key": limit_key, "version": tenant.version, "change_id": change.change_id if change else None, "cleared": value is None, "effective": _limit_response(effective), } 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 InvalidTenantIdentifierError as exc: # TEN-WP-0010: an unknown grouping. Distinct from invalid_update so a # caller can tell "not a grouping" from "nothing changed". raise LifecycleError(400, "invalid_grouping", 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, bearer_token_file=settings.flex_auth_token_file, ) 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)}