Implement TEN-WP-0011 security layer conformance
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
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:
parent
80961af91e
commit
672cf4da6e
40 changed files with 2285 additions and 361 deletions
48
tests/helpers.py
Normal file
48
tests/helpers.py
Normal 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)
|
||||
|
|
@ -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"""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
|
|
|
|||
|
|
@ -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
84
tests/test_audit_core.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
61
tests/test_events_scoped.py
Normal file
61
tests/test_events_scoped.py
Normal 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")
|
||||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
68
tests/test_layer_conformance.py
Normal file
68
tests/test_layer_conformance.py
Normal 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"}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
222
tests/test_pep_write_path.py
Normal file
222
tests/test_pep_write_path.py
Normal 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
91
tests/test_pip_claims.py
Normal 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)
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue