All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m7s
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).
777 lines
28 KiB
Python
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)}
|