tenant-engine/tests/test_api_guardrails.py
tegwick 672cf4da6e
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
Implement TEN-WP-0011 security layer conformance
Engine/PIP declaration is now checkable (layer.yaml plus a Tooling-client
scan). Writes persist a decision record or the published fail-closed
stance, live-lookup freshness is published, events_for is tenant-scoped,
and mutation evidence drains to audit-core from a local outbox without
blocking the mutation.

Sender registration is requested as AUDIT-IN-0002. Boundary-contract
amendment is requested as NET-IN-0002.

Assistant: grok
Assistant-Session: 01a04cea-e5e8-7081-a0fc-808ebbc35fa9
2026-08-29 13:02:51 +02:00

335 lines
12 KiB
Python

"""TEN-WP-0006-T04: HTTP contract for the guardrail surface."""
import pytest
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer, ScopedAuthorizer, deny
from tenant_engine.app import create_app
from tenant_engine.authz import AuthorizationOutcome
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 _TenantScopedAuthorizer:
"""Permits guardrail work on exactly one tenant."""
def __init__(self, permitted_tenant: str) -> None:
self._permitted = permitted_tenant
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
if action == "tenant.create":
return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor)
if tenant_id != self._permitted:
raise deny(action, tenant_id, actor, "not permitted for this tenant")
return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor)
class _BrokenStore(InMemoryTenantStore):
def get_tenant(self, tenant_id: str):
raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused")
class _BrokenWriteStore(InMemoryTenantStore):
def set_guardrail_override(self, **kwargs):
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
# --- Cross-tenant denial and write-path outage ----------------------------
def test_a_caller_cannot_reach_across_tenants():
client = make_client(_TenantScopedAuthorizer("t-own"))
client.post(
"/tenants",
json={"tenant_id": "t-own", "identifier": "tenant:small:own", "actor": "ops"},
)
assert client.get("/tenants/t-own/guardrails", params={"actor": "ops"}).status_code == 200
assert read(client, actor="ops").status_code == 403
assert put(client).status_code == 403
def test_a_cross_tenant_write_changes_nothing():
client = make_client(_TenantScopedAuthorizer("t-own"))
assert put(client).status_code == 403
# the store was never touched: the version is untouched
assert client.app.state.store.get_tenant("t-1").version == 1
def test_a_store_outage_fails_closed_on_write():
client = make_client(store=_BrokenWriteStore())
response = put(client)
assert response.status_code == 503
assert response.json()["error_code"] == "tenant_authority_unavailable"
assert "tenant.db" not in response.text
assert response.json()["correlation_id"] == "corr-1"
def test_errors_never_reflect_policy_internals():
client = make_client(ScopedAuthorizer("tenant.create"))
body = put(client).json()
assert "tenant.db" not in str(body)
assert body["error_code"] == "write_denied"
# --- Compatibility --------------------------------------------------------
def test_existing_endpoints_are_unaffected(client):
assert client.get("/tenants/t-1", params={"actor": "tenant-engine"}).status_code == 200
assert client.get("/tenants/t-1/roles", params={"actor": "tenant-engine"}).status_code == 200
assert client.get("/tenants/t-1/roles/live", params={"actor": "flex-auth"}).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