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:
parent
f631224ab5
commit
b6d016869f
5 changed files with 677 additions and 10 deletions
|
|
@ -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}"'
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue