QONTO-WP-0004-T04: live flex-auth + tenant-engine authorization gate
Replaces the config-only QONTO_ASSISTANT_ENFORCE_SCOPE cached-claim
check with two live-checked facts, per docs/SecurityPractice.md #4:
1. flex-auth POST /v1/check on finance.qonto.read for the calling
actor/tenant (FlexAuthCheckClient, modeled on tenant-engine's own
client for the same API). Registration lives in the flex-auth repo
(examples/qonto-assistant/) -- rules + embedded tests verified with
flex-auth test-policy/load-registry/check, and a live flex-auth
serve hit by this exact client over real HTTP (not a mock).
2. tenant-engine's live capability-role lookup
(GET /tenants/{id}/roles/live), denying unless the tenant currently
holds one of QONTO_TENANT_ENGINE_REQUIRED_ROLES (default VEN,CUS) --
optional and additive to the flex-auth check.
Both clients fail closed by construction (unreachable/malformed/non-2xx
all deny, never grant), matching FlexAuthCheckClient's existing
fail-closed philosophy elsewhere in the fleet. LiveAuthorizationGate
combines both and is wired into CapabilityService._execute ahead of
the internal policy kernel; off by default (no QONTO_FLEX_AUTH_URL
set) so existing deployments are unaffected until configured.
Verified beyond mocked unit tests: ran a real `flex-auth serve` loaded
with the registered policy, and a real tenant-engine instance seeded
with a VEN grant for tenant:friendly:binky, and exercised this repo's
actual FlexAuthCheckClient/TenantEngineClient/LiveAuthorizationGate
against both live processes over real HTTP -- allow for the correct
tenant, live_authz_denied for a mismatched tenant.
28 new unit tests (flex_auth_client, tenant_engine_client,
live_authorization_gate + CapabilityService integration). Full suite
-> 80 passed; REST/MCP smokes and compileall still clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:19:04 +02:00
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
from qonto_assistant.tenant_engine_client import TenantEngineClient
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _client(handler) -> TenantEngineClient:
|
|
|
|
|
return TenantEngineClient(
|
|
|
|
|
base_url="https://tenant-engine.example.test",
|
|
|
|
|
timeout_seconds=1,
|
|
|
|
|
transport=httpx.MockTransport(handler),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_roles_returns_roles_on_200() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
assert request.url.path == "/tenants/tenant:friendly:binky/roles/live"
|
2026-09-05 01:39:48 +02:00
|
|
|
return httpx.Response(
|
|
|
|
|
200, json={"tenant_id": "tenant:friendly:binky", "roles": ["VEN", "CUS"]}
|
|
|
|
|
)
|
QONTO-WP-0004-T04: live flex-auth + tenant-engine authorization gate
Replaces the config-only QONTO_ASSISTANT_ENFORCE_SCOPE cached-claim
check with two live-checked facts, per docs/SecurityPractice.md #4:
1. flex-auth POST /v1/check on finance.qonto.read for the calling
actor/tenant (FlexAuthCheckClient, modeled on tenant-engine's own
client for the same API). Registration lives in the flex-auth repo
(examples/qonto-assistant/) -- rules + embedded tests verified with
flex-auth test-policy/load-registry/check, and a live flex-auth
serve hit by this exact client over real HTTP (not a mock).
2. tenant-engine's live capability-role lookup
(GET /tenants/{id}/roles/live), denying unless the tenant currently
holds one of QONTO_TENANT_ENGINE_REQUIRED_ROLES (default VEN,CUS) --
optional and additive to the flex-auth check.
Both clients fail closed by construction (unreachable/malformed/non-2xx
all deny, never grant), matching FlexAuthCheckClient's existing
fail-closed philosophy elsewhere in the fleet. LiveAuthorizationGate
combines both and is wired into CapabilityService._execute ahead of
the internal policy kernel; off by default (no QONTO_FLEX_AUTH_URL
set) so existing deployments are unaffected until configured.
Verified beyond mocked unit tests: ran a real `flex-auth serve` loaded
with the registered policy, and a real tenant-engine instance seeded
with a VEN grant for tenant:friendly:binky, and exercised this repo's
actual FlexAuthCheckClient/TenantEngineClient/LiveAuthorizationGate
against both live processes over real HTTP -- allow for the correct
tenant, live_authz_denied for a mismatched tenant.
28 new unit tests (flex_auth_client, tenant_engine_client,
live_authorization_gate + CapabilityService integration). Full suite
-> 80 passed; REST/MCP smokes and compileall still clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:19:04 +02:00
|
|
|
|
|
|
|
|
roles = _client(handler).active_roles("tenant:friendly:binky")
|
|
|
|
|
|
|
|
|
|
assert roles == frozenset({"VEN", "CUS"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_roles_empty_when_no_roles() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
return httpx.Response(200, json={"tenant_id": "tenant:friendly:binky", "roles": []})
|
|
|
|
|
|
|
|
|
|
assert _client(handler).active_roles("tenant:friendly:binky") == frozenset()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_roles_fails_closed_on_404_tenant_not_found() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
return httpx.Response(404, json={"detail": "tenant_not_found"})
|
|
|
|
|
|
|
|
|
|
assert _client(handler).active_roles("tenant:friendly:nobody") == frozenset()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_roles_fails_closed_on_503_store_unavailable() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
return httpx.Response(503, json={"detail": "tenant_roles_unavailable"})
|
|
|
|
|
|
|
|
|
|
assert _client(handler).active_roles("tenant:friendly:binky") == frozenset()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_roles_fails_closed_on_malformed_body() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
return httpx.Response(200, content=b"not json")
|
|
|
|
|
|
|
|
|
|
assert _client(handler).active_roles("tenant:friendly:binky") == frozenset()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_roles_fails_closed_on_non_list_roles_field() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
return httpx.Response(200, json={"tenant_id": "tenant:friendly:binky", "roles": "VEN"})
|
|
|
|
|
|
|
|
|
|
assert _client(handler).active_roles("tenant:friendly:binky") == frozenset()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_roles_fails_closed_on_connection_error() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
raise httpx.ConnectError("connection refused", request=request)
|
|
|
|
|
|
|
|
|
|
assert _client(handler).active_roles("tenant:friendly:binky") == frozenset()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_roles_fails_closed_on_timeout() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
raise httpx.TimeoutException("timed out", request=request)
|
|
|
|
|
|
|
|
|
|
assert _client(handler).active_roles("tenant:friendly:binky") == frozenset()
|