All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
Add the PostgreSQL backend, migration and stopped-write transfer tools, lease-aware deployment manifests, tenancy declarations, and shared conformance coverage. Persist grouping mutations in durable stores and separate process liveness from database readiness.
352 lines
12 KiB
Python
352 lines
12 KiB
Python
"""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 _TenantScopedAuthorizer(WriteAuthorizer):
|
|
"""Permits guardrail work on exactly one tenant.
|
|
|
|
Stands in for a flex-auth policy that scopes an operator to their own
|
|
tenant -- the case where a caller is authenticated and permitted in
|
|
general, but not for *this* tenant.
|
|
"""
|
|
|
|
def __init__(self, permitted_tenant: str) -> None:
|
|
self._permitted = permitted_tenant
|
|
|
|
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
|
if action == "tenant.create":
|
|
return
|
|
if tenant_id != self._permitted:
|
|
raise WriteAuthorizationDeniedError(action, "not permitted for this tenant")
|
|
|
|
|
|
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
|