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>
92 lines
3 KiB
Python
92 lines
3 KiB
Python
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from qonto_assistant.flex_auth_client import CheckRequest, FlexAuthCheckClient, new_request_id
|
|
|
|
|
|
def _request() -> CheckRequest:
|
|
return CheckRequest(
|
|
request_id=new_request_id(),
|
|
tenant="tenant:friendly:binky",
|
|
subject_id="agent-harness-binky",
|
|
subject_type="agent",
|
|
)
|
|
|
|
|
|
def _client(handler) -> FlexAuthCheckClient:
|
|
return FlexAuthCheckClient(
|
|
base_url="https://flex-auth.example.test",
|
|
timeout_seconds=1,
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
|
|
|
|
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": {}})
|
|
|
|
assert _client(handler).is_allowed(_request()) is True
|
|
|
|
|
|
@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": {}})
|
|
|
|
assert _client(handler).is_allowed(_request()) is False
|
|
|
|
|
|
def test_non_200_status_denies() -> None:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(500, json={"error": "internal"})
|
|
|
|
assert _client(handler).is_allowed(_request()) is False
|
|
|
|
|
|
def test_malformed_json_body_denies() -> None:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, content=b"not json")
|
|
|
|
assert _client(handler).is_allowed(_request()) is False
|
|
|
|
|
|
def test_non_object_json_body_denies() -> None:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json=["not", "an", "object"])
|
|
|
|
assert _client(handler).is_allowed(_request()) is False
|
|
|
|
|
|
def test_connection_failure_denies() -> None:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
raise httpx.ConnectError("connection refused", request=request)
|
|
|
|
assert _client(handler).is_allowed(_request()) is False
|
|
|
|
|
|
def test_timeout_denies() -> None:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
raise httpx.TimeoutException("timed out", request=request)
|
|
|
|
assert _client(handler).is_allowed(_request()) is False
|
|
|
|
|
|
def test_request_body_matches_schema_shape() -> None:
|
|
seen: dict[str, object] = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen.update(json.loads(request.content))
|
|
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
|
|
|
|
_client(handler).is_allowed(_request())
|
|
|
|
assert seen["tenant"] == "tenant:friendly:binky"
|
|
assert seen["action"] == "finance.qonto.read"
|
|
assert seen["subject"] == {"id": "agent-harness-binky", "type": "agent"}
|
|
assert seen["resource"] == {
|
|
"id": "finance-snapshot",
|
|
"type": "finance-snapshot",
|
|
"system": "qonto-assistant",
|
|
}
|