Finish TEN-WP-0006-T04: expose guardrail read and write APIs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-16 02:18:01 +02:00
parent f631224ab5
commit b6d016869f
5 changed files with 677 additions and 10 deletions

View file

@ -30,6 +30,19 @@ from tenant_engine.domain import (
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,
@ -101,6 +114,39 @@ class LifecycleRequest(BaseModel):
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.
@ -339,9 +385,208 @@ def create_app(
mutate=lambda tenant, at: tenant.reactivate(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}"'

View file

@ -17,6 +17,10 @@ _RESOURCE_TYPES: dict[str, str] = {
"tenant.update": "tenant",
"tenant.retire": "tenant",
"tenant.reactivate": "tenant",
# TEN-WP-0006: reading a ceiling and changing one are separate privileges.
# A PDP needs the read; almost nothing needs the write.
"tenant.guardrail.read": "guardrail",
"tenant.guardrail.set": "guardrail",
}