from __future__ import annotations from datetime import UTC, datetime from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import BaseModel 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, GrantReason, InvalidGrantError, InvalidTenantIdentifierError, PlanAssignment, Tenant, create_role_grant, ) from tenant_engine.flex_auth import FlexAuthCheckClient from tenant_engine.store import ( GrantNotFoundError, InMemoryTenantStore, StoreUnavailableError, TenantAlreadyExistsError, TenantNotFoundError, TenantStore, ) class CreateTenantRequest(BaseModel): tenant_id: str identifier: str actor: str 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 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.get("/health") async def health() -> dict[str, str]: return {"status": "ok", "service": "tenant-engine", "version": __version__} # -- 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) 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} @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 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 return {"tenant_id": tenant_id, "plan_id": payload.plan_id} return app 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)}