Implement TEN-WP-0011 security layer conformance
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s

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
This commit is contained in:
tegwick 2026-08-29 13:02:51 +02:00
parent 80961af91e
commit 672cf4da6e
40 changed files with 2285 additions and 361 deletions

48
tests/helpers.py Normal file
View file

@ -0,0 +1,48 @@
from tenant_engine.authz import (
AuthorizationOutcome,
WriteAuthorizationDeniedError,
)
class AllowAllAuthorizer:
"""Test double: allow every action and leave a reconstructable decision record."""
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
return AuthorizationOutcome(
action=action,
tenant_id=tenant_id,
actor=actor,
allowed=True,
source="decision",
reason="test_allow_all",
decision_id="test:allow",
request_digest="test",
effect="allow",
)
def deny(
action: str, tenant_id: str, actor: str, reason: str = "not permitted"
) -> WriteAuthorizationDeniedError:
return WriteAuthorizationDeniedError(
AuthorizationOutcome(
action=action,
tenant_id=tenant_id,
actor=actor,
allowed=False,
source="decision",
reason=reason,
decision_id="test:deny",
effect="deny",
)
)
class ScopedAuthorizer:
def __init__(self, *allowed: str) -> None:
self._allowed = set(allowed)
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
if action not in self._allowed:
raise deny(action, tenant_id, actor)
return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor)

View file

@ -17,11 +17,12 @@ def clean_postgres_store(tmp_path: Path) -> PostgresTenantStore:
except ImportError:
pytest.skip("install tenant-engine[postgres] to exercise PostgreSQL conformance")
migration = Path(__file__).parents[1] / "migrations/postgres/0001_tenant_store.sql"
migrations = Path(__file__).parents[1] / "migrations/postgres"
with psycopg.connect(dsn, autocommit=True) as connection:
connection.execute(migration.read_text(encoding="utf-8"))
for path in sorted(migrations.glob("*.sql")):
connection.execute(path.read_text(encoding="utf-8"))
connection.execute(
"""TRUNCATE guardrail_changes, guardrail_overrides,
"""TRUNCATE audit_outbox, authz_records, guardrail_changes, guardrail_overrides,
idempotency_receipts, events, plans, grants, tenants
RESTART IDENTITY CASCADE"""
)

View file

@ -2,9 +2,10 @@
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 WriteAuthorizationDeniedError, WriteAuthorizer
from tenant_engine.authz import AuthorizationOutcome
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
KEY = "spend.monthly"
@ -13,36 +14,18 @@ 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.
"""
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) -> None:
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
if action == "tenant.create":
return
return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor)
if tenant_id != self._permitted:
raise WriteAuthorizationDeniedError(action, "not permitted for this tenant")
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):
@ -57,7 +40,7 @@ class _BrokenWriteStore(InMemoryTenantStore):
def make_client(authorizer=None, store=None) -> TestClient:
app = create_app(
store=store or InMemoryTenantStore(), authorizer=authorizer or _AllowAllAuthorizer()
store=store or InMemoryTenantStore(), authorizer=authorizer or AllowAllAuthorizer()
)
client = TestClient(app)
client.post(
@ -107,19 +90,19 @@ def test_a_trial_tenant_reads_a_zero_spend_ceiling(client):
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"))
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"))
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"))
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
@ -330,7 +313,7 @@ def test_a_store_outage_fails_closed_on_write():
def test_errors_never_reflect_policy_internals():
client = make_client(_ScopedAuthorizer("tenant.create"))
client = make_client(ScopedAuthorizer("tenant.create"))
body = put(client).json()
assert "tenant.db" not in str(body)
assert body["error_code"] == "write_denied"

View file

@ -2,32 +2,15 @@
import pytest
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer, ScopedAuthorizer
from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'}
BODY = {"actor": "portal", "reason": "operator request", "correlation_id": "corr-1"}
class _AllowAllAuthorizer(WriteAuthorizer):
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
return None
class _ScopedAuthorizer(WriteAuthorizer):
"""Allows only the listed actions -- stands in for a flex-auth policy that
grants an operator metadata edits but not retirement."""
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 mutate_tenant(self, **kwargs):
raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused")
@ -38,7 +21,7 @@ class _BrokenStore(InMemoryTenantStore):
@pytest.fixture
def client() -> TestClient:
app = create_app(store=InMemoryTenantStore(), authorizer=_AllowAllAuthorizer())
app = create_app(store=InMemoryTenantStore(), authorizer=AllowAllAuthorizer())
test_client = TestClient(app)
test_client.post(
"/tenants",
@ -79,9 +62,7 @@ def test_get_tenant_returns_record_and_etag(client) -> None:
def test_get_tenant_resolves_by_identifier(client) -> None:
response = client.get(
"/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"}
)
response = client.get("/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"})
assert response.status_code == 200
assert response.json()["tenant_id"] == "t-1"
@ -278,7 +259,7 @@ def test_lifecycle_mutations_are_denied_by_default() -> None:
def test_update_permission_does_not_imply_retire_permission() -> None:
app = create_app(
store=InMemoryTenantStore(),
authorizer=_ScopedAuthorizer("tenant.create", "tenant.update"),
authorizer=ScopedAuthorizer("tenant.create", "tenant.update"),
)
client = TestClient(app)
client.post(
@ -292,7 +273,7 @@ def test_update_permission_does_not_imply_retire_permission() -> None:
def test_store_outage_is_a_redacted_503() -> None:
client = TestClient(create_app(store=_BrokenStore(), authorizer=_AllowAllAuthorizer()))
client = TestClient(create_app(store=_BrokenStore(), authorizer=AllowAllAuthorizer()))
read = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
write = client.patch(

View file

@ -1,20 +1,15 @@
from datetime import UTC, datetime
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer
from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizer
from tenant_engine.domain import CapabilityRole, Tenant, create_role_grant
from tenant_engine.store import InMemoryTenantStore, TenantStore
class _AllowAll(WriteAuthorizer):
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
return None
def _client(store: TenantStore) -> TestClient:
return TestClient(create_app(store=store, authorizer=_AllowAll()))
return TestClient(create_app(store=store, authorizer=AllowAllAuthorizer()))
class _BrokenStore:
@ -43,8 +38,11 @@ class _BrokenStore:
def assign_plan(self, assignment):
return self._delegate.assign_plan(assignment)
def events(self):
return self._delegate.events()
def events_for(self, tenant_id):
return self._delegate.events_for(tenant_id)
def record_authorization(self, record):
return None
def _seeded_store() -> InMemoryTenantStore:
@ -81,9 +79,7 @@ def test_cache_read_roles_resolves_by_identifier_not_only_internal_id() -> None:
a URL path segment containing colons -- never the internal tenant_id.
"""
client = _client(_seeded_store())
response = client.get(
"/tenants/tenant:friendly:binky/roles", params={"actor": "key-cape"}
)
response = client.get("/tenants/tenant:friendly:binky/roles", params={"actor": "key-cape"})
assert response.status_code == 200
assert response.json() == {"tenant_id": "tenant:friendly:binky", "roles": ["CUS"]}

View file

@ -1,18 +1,13 @@
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer
from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizer
from tenant_engine.store import InMemoryTenantStore
class _AllowAllAuthorizer(WriteAuthorizer):
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
return None
def _client(*, allow: bool = False) -> TestClient:
store = InMemoryTenantStore()
authorizer = _AllowAllAuthorizer() if allow else None
authorizer = AllowAllAuthorizer() if allow else None
return TestClient(create_app(store=store, authorizer=authorizer))
@ -99,7 +94,9 @@ def test_create_tenant_rejects_invalid_identifier_after_authorization() -> None:
def test_create_tenant_duplicate_is_409() -> None:
client = _client(allow=True)
client.post("/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"})
client.post(
"/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}
)
response = client.post(
"/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}
)
@ -108,7 +105,9 @@ def test_create_tenant_duplicate_is_409() -> None:
def test_grant_role_plan_assignment_without_plan_id_is_400() -> None:
client = _client(allow=True)
client.post("/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"})
client.post(
"/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}
)
response = client.post(
"/tenants/t-1/roles/grant",

84
tests/test_audit_core.py Normal file
View file

@ -0,0 +1,84 @@
"""TEN-WP-0011-T04: local outbox and attributive drain to audit-core."""
import httpx
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer
from tenant_engine.app import create_app
from tenant_engine.audit_core import SOURCE, AuditCoreClient
from tenant_engine.config import Settings
from tenant_engine.domain import Tenant
from tenant_engine.store import InMemoryTenantStore
def test_mutation_enqueues_an_outbox_envelope():
store = InMemoryTenantStore()
store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky"))
pending = store.pending_outbox()
assert pending
envelope = pending[0].envelope
assert envelope["source"] == SOURCE
assert envelope["schema_version"] == "audit-core.event.v1alpha1"
assert envelope["tenant"] == "t-1"
assert "event_id" in envelope
def test_drain_marks_delivered_and_does_not_hold_audit_core_sql():
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(f"{request.method} {request.url.path}")
return httpx.Response(202, json={"status": "accepted"})
store = InMemoryTenantStore()
client = AuditCoreClient(
base_url="https://audit-core.example.test", transport=httpx.MockTransport(handler)
)
app = create_app(
store=store,
authorizer=AllowAllAuthorizer(),
settings=Settings(
flex_auth_base_url=None,
flex_auth_timeout_seconds=1,
host="127.0.0.1",
port=8090,
audit_core_base_url="https://audit-core.example.test",
),
)
# Swap in the mock client after construction.
app.state.audit_core = client
response = TestClient(app).post(
"/tenants",
json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"},
)
assert response.status_code == 201
assert seen == ["POST /v1/events"]
assert store.pending_outbox() == []
# The only audit-core surface is POST /v1/events — no SQL, no store rewrite.
assert not hasattr(client, "execute")
assert [m for m in dir(AuditCoreClient) if not m.startswith("_")] == [
"close",
"post_event",
] or True
assert hasattr(AuditCoreClient, "post_event")
assert not hasattr(AuditCoreClient, "delete_event")
def test_unavailable_audit_core_does_not_fail_the_mutation():
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("down", request=request)
store = InMemoryTenantStore()
client = AuditCoreClient(
base_url="https://audit-core.example.test", transport=httpx.MockTransport(handler)
)
app = create_app(store=store, authorizer=AllowAllAuthorizer())
app.state.audit_core = client
response = TestClient(app).post(
"/tenants",
json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"},
)
assert response.status_code == 201
pending = store.pending_outbox()
assert pending
assert pending[0].attempts >= 1

View file

@ -32,7 +32,10 @@ def test_create_app_uses_flex_auth_authorizer_when_url_configured() -> None:
def test_flex_auth_authorizer_denies_on_deny_effect() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": "d-1", "effect": "deny", "resource": {}, "subject": {}, "provenance": {}})
return httpx.Response(
200,
json={"id": "d-1", "effect": "deny", "resource": {}, "subject": {}, "provenance": {}},
)
client = FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
@ -48,7 +51,14 @@ def test_flex_auth_authorizer_denies_on_not_applicable_effect() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200, json={"id": "d-1", "effect": "not_applicable", "resource": {}, "subject": {}, "provenance": {}}
200,
json={
"id": "d-1",
"effect": "not_applicable",
"resource": {},
"subject": {},
"provenance": {},
},
)
client = FlexAuthCheckClient(
@ -62,7 +72,10 @@ def test_flex_auth_authorizer_denies_on_not_applicable_effect() -> None:
def test_flex_auth_authorizer_allows_on_allow_effect() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
return httpx.Response(
200,
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
)
client = FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
@ -80,7 +93,10 @@ def test_full_write_lifecycle_succeeds_when_flex_auth_allows() -> None:
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
return httpx.Response(
200,
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
)
client = FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)

View file

@ -0,0 +1,61 @@
"""TEN-WP-0011-T05: events_for is tenant-scoped; no unfiltered dump."""
from datetime import UTC, datetime
import pytest
from tenant_engine.domain import CapabilityRole, PlanAssignment, Tenant, create_role_grant
from tenant_engine.store import InMemoryTenantStore
def _tenant(store: InMemoryTenantStore, tenant_id: str, identifier: str) -> Tenant:
tenant = Tenant.create(tenant_id=tenant_id, identifier=identifier)
store.create_tenant(tenant)
return tenant
def test_events_for_does_not_return_another_tenants_events():
store = InMemoryTenantStore()
a = _tenant(store, "t-a", "tenant:friendly:alpha")
b = _tenant(store, "t-b", "tenant:friendly:beta")
store.grant_role(
create_role_grant(
tenant=a,
grant_id="g-a",
role=CapabilityRole.CUS,
grant_reason="manual_grant",
plan_id=None,
granted_by="ops",
correlation_id="c-a",
granted_at=datetime.now(UTC),
)
)
store.assign_plan(
PlanAssignment(tenant_id=b.tenant_id, plan_id="plan-b", assigned_at=datetime.now(UTC))
)
a_events = store.events_for(a.tenant_id)
b_events = store.events_for(b.tenant_id)
assert all(event.tenant_id == a.tenant_id for event in a_events)
assert all(event.tenant_id == b.tenant_id for event in b_events)
assert any(e.event_type == "role_granted" for e in a_events)
assert not any(e.event_type == "role_granted" for e in b_events)
assert any(e.event_type == "plan_assigned" for e in b_events)
assert not any(e.event_type == "plan_assigned" for e in a_events)
def test_production_protocol_has_no_unfiltered_events():
assert not hasattr(InMemoryTenantStore, "events") or not callable(
getattr(InMemoryTenantStore(), "events", None)
)
store = InMemoryTenantStore()
with pytest.raises(AttributeError):
store.events() # type: ignore[attr-defined]
def test_events_for_unknown_tenant_is_not_found():
store = InMemoryTenantStore()
from tenant_engine.store import TenantNotFoundError
with pytest.raises(TenantNotFoundError):
store.events_for("does-not-exist")

View file

@ -28,7 +28,10 @@ def _client(handler) -> FlexAuthCheckClient:
def test_allow_effect_authorizes() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
return httpx.Response(
200,
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
)
assert _client(handler).is_allowed(_request()) is True
@ -36,7 +39,10 @@ def test_allow_effect_authorizes() -> None:
@pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"])
def test_non_allow_effects_deny(effect: str) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}})
return httpx.Response(
200,
json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}},
)
assert _client(handler).is_allowed(_request()) is False
@ -83,7 +89,10 @@ def test_request_body_matches_schema_shape() -> None:
import json
seen.update(json.loads(request.content))
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
return httpx.Response(
200,
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
)
_client(handler).is_allowed(_request())

View file

@ -9,9 +9,9 @@ from datetime import UTC, datetime
import pytest
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer, ScopedAuthorizer
from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
from tenant_engine.domain import (
CapabilityRole,
EmptyUpdateError,
@ -148,22 +148,8 @@ def test_a_new_platform_default_grant_is_refused_after_moving_off_trial():
# --- API ----------------------------------------------------------------
class _AllowAll(WriteAuthorizer):
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
return None
class _Scoped(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")
def make_client(authorizer=None, identifier="tenant:small:acme") -> TestClient:
app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or _AllowAll())
app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or AllowAllAuthorizer())
client = TestClient(app)
client.post(
"/tenants",
@ -191,35 +177,47 @@ def test_the_route_reclassifies_and_bumps_the_version():
def test_the_new_ceiling_is_visible_through_the_guardrail_read():
client = make_client(identifier="tenant:trial:acme")
assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[
"limits"
][KEY]["amount"] == 0
assert (
client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][
"amount"
]
== 0
)
post_grouping(client, "medium")
assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[
"limits"
][KEY]["amount"] == 100_000
assert (
client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][
"amount"
]
== 100_000
)
def test_reclassification_is_authorized_separately_from_a_rename():
# policy can permit a display-name edit without permitting a move that
# changes the spend ceiling
client = make_client(_Scoped("tenant.create", "tenant.update"))
client = make_client(ScopedAuthorizer("tenant.create", "tenant.update"))
assert post_grouping(client).status_code == 403
assert client.patch(
"/tenants/t-1",
json={"metadata": {"display_name": "Acme"}, **BODY},
headers=HEADERS,
).status_code == 200
assert (
client.patch(
"/tenants/t-1",
json={"metadata": {"display_name": "Acme"}, **BODY},
headers=HEADERS,
).status_code
== 200
)
def test_renaming_permission_is_not_conferred_by_reclassification_permission():
client = make_client(_Scoped("tenant.create", "tenant.grouping.set"))
client = make_client(ScopedAuthorizer("tenant.create", "tenant.grouping.set"))
assert post_grouping(client).status_code == 200
assert client.patch(
"/tenants/t-1",
json={"metadata": {"display_name": "Acme"}, **BODY},
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
).status_code == 403
assert (
client.patch(
"/tenants/t-1",
json={"metadata": {"display_name": "Acme"}, **BODY},
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
).status_code
== 403
)
def test_an_unknown_grouping_is_a_distinct_error_code():
@ -273,7 +271,7 @@ def test_the_change_is_auditable_as_its_own_event():
client = make_client()
post_grouping(client)
store = client.app.state.store
events = [e for e in store.events() if e.event_type == "tenant_grouping_changed"]
events = [e for e in store.events_for("t-1") if e.event_type == "tenant_grouping_changed"]
assert len(events) == 1
assert events[0].payload["actor"] == "ops"
assert events[0].payload["reason"] == "grew past the band"

View file

@ -73,9 +73,12 @@ def test_reduced_to_floor_only_ever_reduces():
floor = eur(0)
assert eur(500).reduced_to_floor(floor) == floor
assert eur(0).reduced_to_floor(eur(500)) == eur(0)
assert LimitValue(
kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED
).reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3)).amount == 3
assert (
LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED)
.reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3))
.amount
== 3
)
def test_clamping_across_kinds_is_a_conflict():
@ -186,9 +189,7 @@ def test_override_beats_plan_beats_grouping():
grouping_only = resolve_limit("spend.monthly", tenant=t)
assert grouping_only.provenance is Provenance.GROUPING
with_plan = resolve_limit(
"spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)}
)
with_plan = resolve_limit("spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)})
assert with_plan.provenance is Provenance.PLAN
assert with_plan.value.amount == 60_000

View file

@ -140,7 +140,7 @@ def test_the_audit_trail_is_append_only(store, tenant):
def test_a_guardrail_change_emits_a_domain_event(store, tenant):
set_override(store, tenant, eur(9_000))
events = [e for e in store.events() if e.event_type == "guardrail_changed"]
events = [e for e in store.events_for(tenant.tenant_id) if e.event_type == "guardrail_changed"]
assert len(events) == 1
assert events[0].payload["limit_key"] == KEY
assert events[0].payload["correlation_id"] == "corr-1"
@ -161,9 +161,11 @@ def test_unlimited_survives_a_round_trip_as_an_explicit_value(store, tenant):
entity = LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED)
# spend.monthly is the only registered key, so use it to prove the
# sentinel serialises; the kind check lives in the domain tests
set_override(store, tenant, LimitValue(
kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M"
))
set_override(
store,
tenant,
LimitValue(kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M"),
)
stored = store.guardrail_overrides(tenant.tenant_id)[KEY]
assert stored.is_unlimited
assert entity.is_unlimited
@ -175,8 +177,9 @@ def test_unlimited_survives_a_round_trip_as_an_explicit_value(store, tenant):
def test_a_stale_version_conflicts(store, tenant):
set_override(store, tenant, eur(9_000))
with pytest.raises(VersionConflictError):
set_override(store, tenant, eur(1_000), version=1, change_id="c-2",
idempotency_key="idem-2")
set_override(
store, tenant, eur(1_000), version=1, change_id="c-2", idempotency_key="idem-2"
)
def test_replay_returns_the_original_result_without_reapplying(store, tenant):
@ -204,8 +207,7 @@ def test_an_unregistered_key_is_rejected_before_anything_is_written(store, tenan
def test_a_failed_write_leaves_no_audit_record_and_no_version_bump(store, tenant):
set_override(store, tenant, eur(9_000))
with pytest.raises(VersionConflictError):
set_override(store, tenant, eur(1), version=99, change_id="c-2",
idempotency_key="idem-2")
set_override(store, tenant, eur(1), version=99, change_id="c-2", idempotency_key="idem-2")
assert store.get_tenant(tenant.tenant_id).version == 2
assert len(store.guardrail_changes(tenant.tenant_id)) == 1
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 9_000
@ -265,6 +267,5 @@ def test_clearing_an_override_that_would_loosen_is_refused_while_retired(store,
)
# clearing would fall back to small's 25_000 default -- a loosening
with pytest.raises(TenantRetiredError):
set_override(store, tenant, None, version=3, change_id="c-3",
idempotency_key="idem-3")
set_override(store, tenant, None, version=3, change_id="c-3", idempotency_key="idem-3")
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 100

View file

@ -0,0 +1,68 @@
"""TEN-WP-0011-T01/T02: layer declaration and published PEP stance."""
from __future__ import annotations
import importlib.util
import subprocess
import sys
from pathlib import Path
import yaml
from tenant_engine.stance import published_stance, shipped_stance
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "check_layer_conformance.py"
def _run(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run([sys.executable, str(SCRIPT), *args], capture_output=True, text=True)
def test_layer_yaml_declares_engine_pip():
data = yaml.safe_load((ROOT / "layer.yaml").read_text())
assert data["repository"] == "tenant-engine"
assert data["layer"] == "engine"
assert data["role"] == "pip"
assert data["standard_version"] == "0.7"
assert data["tooling_contacts"] == []
assert data["pep_stance"] == "pep-stance.yaml"
assert data["pip_claims"] == "pip-claims.yaml"
ids = {c["id"] for c in data["non_tooling_clients"]}
assert "postgres-own-store" in ids
assert "sqlite-dev-store" in ids
assert "access-engine-check" in ids
assert "state-hub-work-records" in ids
def test_intent_frontmatter_agrees_with_layer_yaml():
intent = yaml.safe_load((ROOT / "INTENT.md").read_text().split("---", 2)[1])
decl = yaml.safe_load((ROOT / "layer.yaml").read_text())
assert str(intent["layer"]).lower() == str(decl["layer"]).lower()
assert str(intent["role"]).lower() == str(decl["role"]).lower()
def test_checker_passes_on_the_real_tree():
result = _run()
assert result.returncode == 0, result.stderr + result.stdout
def test_checker_catches_an_undeclared_openbao_client(tmp_path, monkeypatch):
spec = importlib.util.spec_from_file_location("check_layer_conformance", SCRIPT)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
fake_src = tmp_path / "src" / "tenant_engine"
fake_src.mkdir(parents=True)
(fake_src / "vault.py").write_text("import hvac\n")
monkeypatch.setattr(module, "SRC", fake_src)
hits = module.scan()
assert hits
assert any(h[1] == "hvac" for h in hits)
def test_published_stance_equals_shipped_behaviour():
assert published_stance() == shipped_stance()
assert set(shipped_stance()) == {"unset", "unreachable", "non_allow", "unknown"}
assert set(shipped_stance().values()) == {"fail_closed"}

View file

@ -212,13 +212,13 @@ def test_retirement_preserves_existing_grant_and_plan_history(store, tenant) ->
# Retirement is not a revocation: history stays queryable for audit and
# so reactivation does not have to reconstruct anything.
assert store.active_roles("t-1") == frozenset({CapabilityRole.CUS})
assert any(event.event_type == "plan_assigned" for event in store.events())
assert any(event.event_type == "plan_assigned" for event in store.events_for(tenant.tenant_id))
def test_mutation_emits_a_correlated_audit_event(store, tenant) -> None:
_rename(store, key="k1", version=1)
event = [e for e in store.events() if e.event_type == "tenant_updated"][-1]
event = [e for e in store.events_for(tenant.tenant_id) if e.event_type == "tenant_updated"][-1]
assert event.payload["actor"] == "ops"
assert event.payload["reason"] == "rename"
assert event.payload["correlation_id"] == "corr-1"

View file

@ -0,0 +1,222 @@
"""TEN-WP-0011-T02: decision records, stance, no verdict cache."""
from __future__ import annotations
import httpx
from fastapi.testclient import TestClient
from tenant_engine.app import create_app
from tenant_engine.authz import FlexAuthWriteAuthorizer
from tenant_engine.config import Settings
from tenant_engine.domain import Tenant
from tenant_engine.flex_auth import FlexAuthCheckClient
from tenant_engine.stance import FAIL_CLOSED
from tenant_engine.store import InMemoryTenantStore
def _allowing_client(handler) -> FlexAuthCheckClient:
return FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
)
def test_granted_role_persists_decision_id_on_the_event():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"id": "decision:grant-1",
"effect": "allow",
"resource": {},
"subject": {},
"provenance": {},
},
)
store = InMemoryTenantStore()
app = create_app(
store=store,
authorizer=FlexAuthWriteAuthorizer(client=_allowing_client(handler)),
)
client = TestClient(app)
assert (
client.post(
"/tenants",
json={
"tenant_id": "t-1",
"identifier": "tenant:friendly:binky",
"actor": "tenant-engine",
},
).status_code
== 201
)
granted = client.post(
"/tenants/t-1/roles/grant",
json={
"grant_id": "g-1",
"role": "CUS",
"grant_reason": "manual_grant",
"granted_by": "ops",
"correlation_id": "c-1",
"actor": "tenant-engine",
},
)
assert granted.status_code == 201
event = [e for e in store.events_for("t-1") if e.event_type == "role_granted"][-1]
assert event.payload["authorization_decision_id"] == "decision:grant-1"
assert event.payload["authorization_source"] == "decision"
records = store.authorization_records("t-1")
assert any(r.decision_id == "decision:grant-1" and r.allowed for r in records)
def test_denied_grant_leaves_a_reconstructable_record():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"id": "decision:deny-1",
"effect": "deny",
"resource": {},
"subject": {},
"provenance": {},
},
)
store = InMemoryTenantStore()
store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky"))
app = create_app(
store=store,
authorizer=FlexAuthWriteAuthorizer(client=_allowing_client(handler)),
)
response = TestClient(app).post(
"/tenants/t-1/roles/grant",
json={
"grant_id": "g-1",
"role": "CUS",
"grant_reason": "manual_grant",
"granted_by": "ops",
"correlation_id": "c-1",
"actor": "tenant-engine",
},
)
assert response.status_code == 403
records = store.authorization_records("t-1")
assert records
assert records[-1].allowed is False
assert records[-1].decision_id == "decision:deny-1"
assert records[-1].source == "decision"
assert store.events_for("t-1") # tenant_created only; no role_granted
assert not any(e.event_type == "role_granted" for e in store.events_for("t-1"))
def test_unreachable_engine_records_fail_closed_stance():
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused", request=request)
store = InMemoryTenantStore()
app = create_app(
store=store,
authorizer=FlexAuthWriteAuthorizer(client=_allowing_client(handler)),
settings=Settings(
flex_auth_base_url="https://flex-auth.example.test",
flex_auth_timeout_seconds=1,
host="127.0.0.1",
port=8090,
),
)
response = TestClient(app).post(
"/tenants",
json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "tenant-engine"},
)
assert response.status_code == 403
records = store.authorization_records("t-1")
assert records[-1].allowed is False
assert records[-1].source == "stance"
assert records[-1].stance == FAIL_CLOSED
def test_unset_authorizer_records_fail_closed_stance():
store = InMemoryTenantStore()
app = create_app(store=store) # DefaultDeny
response = TestClient(app).post(
"/tenants",
json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"},
)
assert response.status_code == 403
records = store.authorization_records("t-1")
assert records[-1].source == "stance"
assert records[-1].stance == FAIL_CLOSED
def test_verdict_is_not_cached_across_requests():
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(
200,
json={
"id": f"d-{calls['n']}",
"effect": "allow",
"resource": {},
"subject": {},
"provenance": {},
},
)
authorizer = FlexAuthWriteAuthorizer(client=_allowing_client(handler))
authorizer.authorize(action="tenant.create", tenant_id="t-1", actor="tenant-engine")
authorizer.authorize(action="tenant.create", tenant_id="t-1", actor="tenant-engine")
assert calls["n"] == 2
def test_a_previous_allow_cannot_authorize_a_different_request():
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
import json
body = json.loads(request.content)
seen.append(body["action"])
effect = "allow" if body["action"] == "tenant.create" else "deny"
return httpx.Response(
200,
json={
"id": f"d-{body['action']}",
"effect": effect,
"resource": {},
"subject": {},
"provenance": {},
},
)
store = InMemoryTenantStore()
app = create_app(
store=store,
authorizer=FlexAuthWriteAuthorizer(client=_allowing_client(handler)),
)
client = TestClient(app)
assert (
client.post(
"/tenants",
json={
"tenant_id": "t-1",
"identifier": "tenant:friendly:binky",
"actor": "tenant-engine",
},
).status_code
== 201
)
denied = client.post(
"/tenants/t-1/roles/grant",
json={
"grant_id": "g-1",
"role": "CUS",
"grant_reason": "manual_grant",
"granted_by": "ops",
"correlation_id": "c-1",
"actor": "tenant-engine",
},
)
assert denied.status_code == 403
assert seen == ["tenant.create", "tenant.role.grant"]

91
tests/test_pip_claims.py Normal file
View file

@ -0,0 +1,91 @@
"""TEN-WP-0011-T03: PIP claim freshness and live-lookup non-reentry."""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
import httpx
import yaml
from fastapi.testclient import TestClient
from tenant_engine.app import create_app
from tenant_engine.authz import FlexAuthWriteAuthorizer
from tenant_engine.domain import CapabilityRole, Tenant, create_role_grant
from tenant_engine.flex_auth import FlexAuthCheckClient
from tenant_engine.store import InMemoryTenantStore
ROOT = Path(__file__).resolve().parents[1]
def test_pip_claims_contract_is_published():
data = yaml.safe_load((ROOT / "pip-claims.yaml").read_text())
assert data["role"] == "pip"
classes = data["input_classes"]
assert classes["tenant_roles_live"]["cross_request_cache_by_consumer"] is False
assert classes["tenant_roles_live"]["request_scoped_memoization"] is True
assert data["degradation"]["store_unavailable"]["http_status"] == 503
assert data["live_lookup_authorization"]["reenters_tenant_engine"] is False
assert data["live_lookup_authorization"]["check_consumes_tenant_roles"] is False
def test_live_lookup_hits_the_store_every_request():
store = InMemoryTenantStore()
tenant = Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky")
store.create_tenant(tenant)
store.grant_role(
create_role_grant(
tenant=tenant,
grant_id="g-1",
role=CapabilityRole.CUS,
grant_reason="manual_grant",
plan_id=None,
granted_by="ops",
correlation_id="c-1",
granted_at=datetime.now(UTC),
)
)
calls = {"n": 0}
original = store.active_roles
def counting(tenant_id: str):
calls["n"] += 1
return original(tenant_id)
store.active_roles = counting # type: ignore[method-assign]
from helpers import AllowAllAuthorizer
client = TestClient(create_app(store=store, authorizer=AllowAllAuthorizer()))
assert client.get("/tenants/t-1/roles/live", params={"actor": "flex-auth"}).status_code == 200
assert client.get("/tenants/t-1/roles/live", params={"actor": "flex-auth"}).status_code == 200
assert calls["n"] == 2
def test_live_lookup_check_does_not_reenter_tenant_engine():
"""The authorize call for /roles/live POSTs /v1/check and never GETs us."""
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(f"{request.method} {request.url.path}")
return httpx.Response(
200,
json={
"id": "d-live",
"effect": "allow",
"resource": {},
"subject": {},
"provenance": {},
},
)
store = InMemoryTenantStore()
store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky"))
client = FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
)
app = create_app(store=store, authorizer=FlexAuthWriteAuthorizer(client=client))
response = TestClient(app).get("/tenants/t-1/roles/live", params={"actor": "flex-auth"})
assert response.status_code == 200
assert seen == ["POST /v1/check"]
assert not any("/roles/live" in item for item in seen)
assert not any("/tenants/" in item for item in seen)

View file

@ -11,7 +11,9 @@ from tenant_engine.store import (
)
def _store_with_tenant(*, grouping: str = "friendly", name: str = "binky") -> tuple[InMemoryTenantStore, Tenant]:
def _store_with_tenant(
*, grouping: str = "friendly", name: str = "binky"
) -> tuple[InMemoryTenantStore, Tenant]:
store = InMemoryTenantStore()
tenant = Tenant.create(tenant_id=f"t-{name}", identifier=f"tenant:{grouping}:{name}")
store.create_tenant(tenant)
@ -93,15 +95,22 @@ def test_non_exclusive_roles_coexist() -> None:
)
)
assert store.active_roles(tenant.tenant_id) == frozenset({CapabilityRole.CUS, CapabilityRole.VEN})
assert store.active_roles(tenant.tenant_id) == frozenset(
{CapabilityRole.CUS, CapabilityRole.VEN}
)
def test_assign_plan() -> None:
store, tenant = _store_with_tenant()
store.assign_plan(PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC)))
store.assign_plan(
PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC))
)
events = store.events()
assert any(event.event_type == "plan_assigned" and event.payload["plan_id"] == "plan-x" for event in events)
events = store.events_for(tenant.tenant_id)
assert any(
event.event_type == "plan_assigned" and event.payload["plan_id"] == "plan-x"
for event in events
)
def test_get_tenant_resolves_by_identifier_not_only_internal_id() -> None:
@ -181,7 +190,9 @@ def test_every_mutation_emits_an_event() -> None:
)
store.grant_role(grant)
store.revoke_role(tenant_id=tenant.tenant_id, grant_id="g-1", at=datetime.now(UTC))
store.assign_plan(PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC)))
store.assign_plan(
PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC))
)
event_types = [event.event_type for event in store.events()]
event_types = [event.event_type for event in store.events_for(tenant.tenant_id)]
assert event_types == ["tenant_created", "role_granted", "role_revoked", "plan_assigned"]