tenant-engine/src/tenant_engine/app.py
tegwick 2063470ac8
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m7s
Send a projected flex-auth caller token on every check
TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE is read per request so hourly
projection rotation needs no restart. Missing or unreadable file fails
closed as a local deny and never calls flex-auth. Needed before
flex-auth-tenant-engine can enforce (FLEX-WP-0015-T02).
2026-08-19 14:31:39 +02:00

777 lines
28 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.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)}