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
|
|
@ -229,21 +229,106 @@ by accident.
|
|||
}
|
||||
```
|
||||
|
||||
Endpoint shapes, headers, error codes, and the flex-auth actions that gate
|
||||
them are specified in T04 and documented here on completion.
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /tenants/{tenant_id}/guardrails?actor=<actor>`
|
||||
|
||||
Every registered key, resolved, with provenance — the shape above. `actor` is
|
||||
required: the read is authorized, so there is no anonymous caller.
|
||||
|
||||
### `PUT /tenants/{tenant_id}/guardrails/{limit_key}`
|
||||
|
||||
```http
|
||||
PUT /tenants/t-1/guardrails/spend.monthly
|
||||
Idempotency-Key: 4f1c…
|
||||
If-Match: "1"
|
||||
|
||||
{"limit": {"kind": "spend", "amount": "9000", "currency": "EUR", "period": "P1M"},
|
||||
"actor": "ops", "reason": "raised for pilot", "correlation_id": "corr-1"}
|
||||
```
|
||||
|
||||
`amount` is a **string** so the `unlimited` sentinel and an integer share one
|
||||
field, and so a spend amount in minor units never round-trips through a float.
|
||||
|
||||
### `DELETE /tenants/{tenant_id}/guardrails/{limit_key}`
|
||||
|
||||
Same headers; body is `{"actor", "reason", "correlation_id"}`. Clears the
|
||||
override so resolution falls back to the plan, then the grouping default.
|
||||
|
||||
Both mutations return the new effective limit plus `ETag`, `change_id`, and
|
||||
`Idempotent-Replay: true|false`:
|
||||
|
||||
```json
|
||||
{"tenant_id": "t-1", "limit_key": "spend.monthly", "version": 2,
|
||||
"change_id": "9f2c…", "cleared": false,
|
||||
"effective": {"kind": "spend", "amount": 9000, "currency": "EUR",
|
||||
"period": "P1M", "provenance": "override"}}
|
||||
```
|
||||
|
||||
A guardrail change **bumps the tenant's version**, so it invalidates the
|
||||
record ETag exactly as a metadata edit does. There is no separately versioned
|
||||
guardrail resource to race against the tenant record.
|
||||
|
||||
`change_id` is derived from `(tenant_id, limit_key, Idempotency-Key)`, not
|
||||
random, so a genuine retry replays the original audit record rather than
|
||||
minting a second one for a mutation that happened once.
|
||||
|
||||
---
|
||||
|
||||
## Authorization
|
||||
|
||||
Two **distinct** actions, so policy can give `flex-auth` the read without
|
||||
giving anything the write:
|
||||
|
||||
| Action | Resource type |
|
||||
|---|---|
|
||||
| `tenant.guardrail.read` | `guardrail` |
|
||||
| `tenant.guardrail.set` | `guardrail` |
|
||||
|
||||
These extend package `tenant-engine.write-api.mutate` alongside the existing
|
||||
seven actions. **Both are new and require a policy-package change in
|
||||
`flex-auth` before this surface functions in production** — until then every
|
||||
check correctly resolves to deny.
|
||||
|
||||
Authorization runs **before** the store is touched on every guardrail route,
|
||||
so an unauthorized caller cannot use status codes or timing to probe which
|
||||
tenants exist or which limit keys are registered. A denied read of a real
|
||||
tenant and a denied read of a nonexistent one are byte-identical.
|
||||
|
||||
---
|
||||
|
||||
## Errors
|
||||
|
||||
| `error_code` | Cause |
|
||||
|---|---|
|
||||
| `unknown_limit_key` | key is not in the registry |
|
||||
| `guardrail_registry_invalid` | startup validation failed — a grouping has no default |
|
||||
| Status | `error_code` | Cause |
|
||||
|---|---|---|
|
||||
| 400 | `idempotency_key_required` | `Idempotency-Key` header missing |
|
||||
| 400 | `invalid_if_match` | `If-Match` is `*` or not a version ETag |
|
||||
| 400 | `invalid_limit` | malformed limit — negative amount, spend without currency, wrong shape |
|
||||
| 403 | `write_denied` | flex-auth denied the action |
|
||||
| 404 | `tenant_not_found` | unknown tenant |
|
||||
| 404 | `unknown_limit_key` | key is not in the registry |
|
||||
| 409 | `version_conflict` | stale `If-Match` — re-read and retry |
|
||||
| 409 | `idempotency_key_conflict` | key reused for a different request |
|
||||
| 409 | `guardrail_loosening_denied` | change would raise a retired tenant's ceiling |
|
||||
| 422 | *(schema)* | unknown body field, or empty `reason`/`correlation_id` |
|
||||
| 428 | `if_match_required` | `If-Match` header missing |
|
||||
| 503 | `tenant_authority_unavailable` | store or authority unavailable |
|
||||
| — | `guardrail_registry_invalid` | startup validation failed — a grouping has no default |
|
||||
|
||||
Lifecycle, authorization, concurrency, and idempotency errors are unchanged
|
||||
from `tenant-lifecycle-api.md`; guardrail writes use the same mutation
|
||||
contract (`Idempotency-Key`, `If-Match`, actor, reason, correlation id).
|
||||
`guardrail_registry_invalid` is a **startup** failure, not a response: the
|
||||
service refuses to come up rather than serving an incomplete registry.
|
||||
|
||||
Errors are redacted: never a policy internal, a store path, or a registry
|
||||
dump in `detail`.
|
||||
|
||||
---
|
||||
|
||||
## What is not wired yet
|
||||
|
||||
The **plan-derived layer has no feed.** Precedence layer 2 exists, resolves,
|
||||
and is tested, but nothing populates it: `adaptive-pricing` owns plan terms
|
||||
and does not yet expose plan-derived ceilings. Until it does, a tenant's
|
||||
limits come from its override or its grouping default. Inventing a plan→limit
|
||||
mapping here would duplicate terms this repo does not own.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
292
tests/test_api_guardrails.py
Normal file
292
tests/test_api_guardrails.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"""TEN-WP-0006-T04: HTTP contract for the guardrail surface."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tenant_engine.app import create_app
|
||||
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
|
||||
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
|
||||
|
||||
KEY = "spend.monthly"
|
||||
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'}
|
||||
LIMIT = {"kind": "spend", "amount": "9000", "currency": "EUR", "period": "P1M"}
|
||||
BODY = {"actor": "ops", "reason": "raised for pilot", "correlation_id": "corr-1"}
|
||||
|
||||
|
||||
class _AllowAllAuthorizer(WriteAuthorizer):
|
||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _ScopedAuthorizer(WriteAuthorizer):
|
||||
def __init__(self, *allowed: str) -> None:
|
||||
self._allowed = set(allowed)
|
||||
|
||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
||||
if action not in self._allowed:
|
||||
raise WriteAuthorizationDeniedError(action, "not permitted")
|
||||
|
||||
|
||||
class _BrokenStore(InMemoryTenantStore):
|
||||
def get_tenant(self, tenant_id: str):
|
||||
raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused")
|
||||
|
||||
|
||||
def make_client(authorizer=None, store=None) -> TestClient:
|
||||
app = create_app(
|
||||
store=store or InMemoryTenantStore(), authorizer=authorizer or _AllowAllAuthorizer()
|
||||
)
|
||||
client = TestClient(app)
|
||||
client.post(
|
||||
"/tenants",
|
||||
json={"tenant_id": "t-1", "identifier": "tenant:small:acme", "actor": "ops"},
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return make_client()
|
||||
|
||||
|
||||
def read(client, actor="flex-auth"):
|
||||
return client.get("/tenants/t-1/guardrails", params={"actor": actor})
|
||||
|
||||
|
||||
def put(client, *, limit=None, headers=None, body=None, key=KEY):
|
||||
return client.put(
|
||||
f"/tenants/t-1/guardrails/{key}",
|
||||
json={"limit": limit or LIMIT, **(body or BODY)},
|
||||
headers=headers or HEADERS,
|
||||
)
|
||||
|
||||
|
||||
# --- Read -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_read_returns_effective_limits_with_provenance(client):
|
||||
response = read(client)
|
||||
assert response.status_code == 200
|
||||
limit = response.json()["limits"][KEY]
|
||||
assert limit["provenance"] == "grouping"
|
||||
assert limit["amount"] == 25_000
|
||||
assert limit["currency"] == "EUR"
|
||||
|
||||
|
||||
def test_a_trial_tenant_reads_a_zero_spend_ceiling(client):
|
||||
client.post(
|
||||
"/tenants",
|
||||
json={"tenant_id": "t-2", "identifier": "tenant:trial:pilot", "actor": "ops"},
|
||||
)
|
||||
response = client.get("/tenants/t-2/guardrails", params={"actor": "flex-auth"})
|
||||
assert response.json()["limits"][KEY]["amount"] == 0
|
||||
|
||||
|
||||
def test_read_is_authorized_separately_from_write():
|
||||
# a PDP gets the read and nothing else
|
||||
client = make_client(_ScopedAuthorizer("tenant.create", "tenant.guardrail.read"))
|
||||
assert read(client).status_code == 200
|
||||
assert put(client).status_code == 403
|
||||
|
||||
|
||||
def test_write_permission_does_not_confer_read_permission():
|
||||
client = make_client(_ScopedAuthorizer("tenant.create", "tenant.guardrail.set"))
|
||||
assert read(client).status_code == 403
|
||||
assert put(client).status_code == 200
|
||||
|
||||
|
||||
def test_an_unauthorized_read_cannot_probe_tenant_existence():
|
||||
client = make_client(_ScopedAuthorizer("tenant.create"))
|
||||
known = client.get("/tenants/t-1/guardrails", params={"actor": "nobody"})
|
||||
unknown = client.get("/tenants/t-404/guardrails", params={"actor": "nobody"})
|
||||
assert known.status_code == unknown.status_code == 403
|
||||
assert known.json() == unknown.json()
|
||||
|
||||
|
||||
def test_a_store_outage_fails_closed_on_read():
|
||||
client = make_client(store=_BrokenStore())
|
||||
response = read(client)
|
||||
assert response.status_code == 503
|
||||
assert response.json()["error_code"] == "tenant_authority_unavailable"
|
||||
assert "tenant.db" not in response.text
|
||||
|
||||
|
||||
# --- Write ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_setting_an_override_returns_the_new_effective_limit(client):
|
||||
response = put(client)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["effective"]["amount"] == 9_000
|
||||
assert body["effective"]["provenance"] == "override"
|
||||
assert body["version"] == 2
|
||||
assert response.headers["ETag"] == '"2"'
|
||||
assert response.headers["Idempotent-Replay"] == "false"
|
||||
|
||||
|
||||
def test_the_override_shows_up_in_a_subsequent_read(client):
|
||||
put(client)
|
||||
assert read(client).json()["limits"][KEY]["provenance"] == "override"
|
||||
|
||||
|
||||
def test_clearing_falls_back_to_the_grouping_default(client):
|
||||
put(client)
|
||||
response = client.request(
|
||||
"DELETE",
|
||||
f"/tenants/t-1/guardrails/{KEY}",
|
||||
json=BODY,
|
||||
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["cleared"] is True
|
||||
assert response.json()["effective"]["provenance"] == "grouping"
|
||||
|
||||
|
||||
def test_replay_returns_the_same_result_without_reapplying(client):
|
||||
first = put(client)
|
||||
second = put(client)
|
||||
assert second.headers["Idempotent-Replay"] == "true"
|
||||
assert second.json()["version"] == first.json()["version"] == 2
|
||||
assert second.json()["change_id"] == first.json()["change_id"]
|
||||
|
||||
|
||||
def test_reusing_a_key_for_a_different_limit_conflicts(client):
|
||||
put(client)
|
||||
response = put(
|
||||
client,
|
||||
limit={**LIMIT, "amount": "1000"},
|
||||
headers={"Idempotency-Key": "idem-1", "If-Match": '"2"'},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["error_code"] == "idempotency_key_conflict"
|
||||
|
||||
|
||||
def test_a_stale_if_match_conflicts(client):
|
||||
put(client)
|
||||
response = put(client, headers={"Idempotency-Key": "idem-2", "If-Match": '"1"'})
|
||||
assert response.status_code == 409
|
||||
assert response.json()["error_code"] == "version_conflict"
|
||||
|
||||
|
||||
def test_if_match_and_idempotency_key_are_required(client):
|
||||
assert put(client, headers={"Idempotency-Key": "idem-9"}).status_code == 428
|
||||
missing_key = put(client, headers={"If-Match": '"1"'})
|
||||
assert missing_key.status_code == 400
|
||||
assert missing_key.json()["error_code"] == "idempotency_key_required"
|
||||
|
||||
|
||||
def test_if_match_star_is_rejected(client):
|
||||
response = put(client, headers={"Idempotency-Key": "idem-9", "If-Match": "*"})
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error_code"] == "invalid_if_match"
|
||||
|
||||
|
||||
def test_an_unregistered_limit_key_is_rejected(client):
|
||||
response = put(client, key="spend.weekly")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["error_code"] == "unknown_limit_key"
|
||||
|
||||
|
||||
def test_a_malformed_limit_is_rejected(client):
|
||||
# a spend limit without a currency is not a spend limit
|
||||
response = put(client, limit={"kind": "spend", "amount": "100", "period": "P1M"})
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error_code"] == "invalid_limit"
|
||||
|
||||
|
||||
def test_a_negative_amount_is_rejected(client):
|
||||
response = put(client, limit={**LIMIT, "amount": "-1"})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_unknown_body_fields_are_rejected_by_the_schema(client):
|
||||
response = client.put(
|
||||
f"/tenants/t-1/guardrails/{KEY}",
|
||||
json={"limit": LIMIT, **BODY, "surprise": 1},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_reason_and_correlation_id_are_required(client):
|
||||
response = client.put(
|
||||
f"/tenants/t-1/guardrails/{KEY}",
|
||||
json={"limit": LIMIT, "actor": "ops", "reason": "", "correlation_id": "c"},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_an_unlimited_override_must_be_stated_explicitly(client):
|
||||
response = put(client, limit={**LIMIT, "amount": "unlimited"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["effective"]["amount"] == "unlimited"
|
||||
|
||||
|
||||
def test_an_unknown_tenant_is_not_found(client):
|
||||
response = client.put(
|
||||
f"/tenants/t-404/guardrails/{KEY}",
|
||||
json={"limit": LIMIT, **BODY},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["error_code"] == "tenant_not_found"
|
||||
|
||||
|
||||
# --- Retired tenants ------------------------------------------------------
|
||||
|
||||
|
||||
def retire(client):
|
||||
return client.post(
|
||||
"/tenants/t-1/retire",
|
||||
json={"actor": "ops", "reason": "test", "correlation_id": "corr-r"},
|
||||
headers={"Idempotency-Key": "retire-1", "If-Match": '"1"'},
|
||||
)
|
||||
|
||||
|
||||
def test_a_retired_tenant_still_reads_its_guardrails(client):
|
||||
retire(client)
|
||||
response = read(client)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["limits"][KEY]["provenance"] == "lifecycle"
|
||||
assert response.json()["limits"][KEY]["amount"] == 0
|
||||
|
||||
|
||||
def test_loosening_a_retired_tenants_guardrail_is_refused(client):
|
||||
retire(client)
|
||||
# 90 000 is above small's 25 000 default -- inert while retired, but it
|
||||
# would take effect the moment the tenant is reactivated
|
||||
response = put(
|
||||
client,
|
||||
limit={**LIMIT, "amount": "90000"},
|
||||
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["error_code"] == "guardrail_loosening_denied"
|
||||
|
||||
|
||||
def test_tightening_a_retired_tenants_guardrail_is_allowed(client):
|
||||
retire(client)
|
||||
response = put(
|
||||
client,
|
||||
limit={**LIMIT, "amount": "100"},
|
||||
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# --- Compatibility --------------------------------------------------------
|
||||
|
||||
|
||||
def test_existing_endpoints_are_unaffected(client):
|
||||
assert client.get("/tenants/t-1").status_code == 200
|
||||
assert client.get("/tenants/t-1/roles").status_code == 200
|
||||
assert client.get("/tenants/t-1/roles/live").status_code == 200
|
||||
assert client.get("/health").status_code == 200
|
||||
|
||||
|
||||
def test_the_guardrail_routes_are_in_the_openapi_document(client):
|
||||
paths = client.get("/openapi.json").json()["paths"]
|
||||
assert "/tenants/{tenant_id}/guardrails" in paths
|
||||
assert "/tenants/{tenant_id}/guardrails/{limit_key}" in paths
|
||||
|
|
@ -235,7 +235,7 @@ absence — the one thing the contract forbids.
|
|||
|
||||
```task
|
||||
id: TEN-WP-0006-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "4a256517-2773-4679-ad57-2909f22ac8a4"
|
||||
```
|
||||
|
|
@ -263,6 +263,47 @@ TEN-WP-0005-T05.
|
|||
Done when the routes are authorized, versioned, idempotent, correlated, and
|
||||
provider-neutral, and the OpenAPI document makes the semantics unambiguous.
|
||||
|
||||
Done 2026-08-16. Three routes: `GET /tenants/{id}/guardrails` (effective
|
||||
limits plus provenance), `PUT` and `DELETE
|
||||
/tenants/{id}/guardrails/{limit_key}`. 26 API tests; 227 total, all passing.
|
||||
Contract documented in `docs/tenant-guardrail-policy.md`.
|
||||
|
||||
**Required flex-auth actions — the tracked handoff this task exists to make
|
||||
explicit:**
|
||||
|
||||
| Action | Resource type | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `tenant.guardrail.read` | `guardrail` | read a tenant's effective ceilings |
|
||||
| `tenant.guardrail.set` | `guardrail` | set or clear a per-tenant override |
|
||||
|
||||
Both are **new** and need a policy-package change in `flex-auth` before this
|
||||
surface functions in production. Until then every check correctly resolves to
|
||||
deny. See the regression note below — these land on a package that is
|
||||
currently *behind*, not merely one that needs extending.
|
||||
|
||||
Decisions:
|
||||
|
||||
- **The read is authorized too**, unlike the existing `/roles` and
|
||||
`/roles/live` reads. A ceiling is policy about a tenant's commercial
|
||||
exposure, not a capability claim, and the task's whole point was that policy
|
||||
must be able to permit reading a limit without permitting a change. `actor`
|
||||
is a required query parameter, so there is no anonymous caller. Tested both
|
||||
ways: read-only permission cannot write, write-only permission cannot read.
|
||||
- **`amount` is a string on the wire.** It lets the `unlimited` sentinel and
|
||||
an integer share one field without a JSON union, and keeps a spend amount in
|
||||
minor units from ever round-tripping through a float.
|
||||
- **`change_id` is derived** from `(tenant_id, limit_key, Idempotency-Key)`
|
||||
rather than random, so a genuine retry replays the original audit record
|
||||
instead of minting a second one for a mutation that happened once.
|
||||
- **Authorization precedes every store touch**, so a denied read of a real
|
||||
tenant and of a nonexistent one are byte-identical — no probing existence or
|
||||
registry contents through status codes.
|
||||
- A retired tenant's refused loosening gets its own code,
|
||||
`guardrail_loosening_denied`, rather than reusing
|
||||
`invalid_lifecycle_transition`: the tenant's lifecycle is not in transition,
|
||||
and a caller should be able to tell "your tenant is retired, tighten instead"
|
||||
from "your retire/reactivate call was invalid".
|
||||
|
||||
## T05 - Conformance and consumer handoff
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue