From 1f0f979e3684f2c68116cf5cc175a093371ff8f5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 24 Jul 2026 00:19:04 +0200 Subject: [PATCH] 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 --- docs/operator-runbook.md | 22 +++ src/qonto_assistant/app.py | 25 +++ src/qonto_assistant/config.py | 14 ++ src/qonto_assistant/flex_auth_client.py | 105 +++++++++++++ src/qonto_assistant/live_authorization.py | 65 ++++++++ src/qonto_assistant/service.py | 29 +++- src/qonto_assistant/tenant_engine_client.py | 56 +++++++ tests/test_api.py | 5 + tests/test_flex_auth_client.py | 92 +++++++++++ tests/test_key_cape_auth.py | 5 + tests/test_live_authorization_gate.py | 162 ++++++++++++++++++++ tests/test_tenant_engine_client.py | 70 +++++++++ 12 files changed, 649 insertions(+), 1 deletion(-) create mode 100644 src/qonto_assistant/flex_auth_client.py create mode 100644 src/qonto_assistant/live_authorization.py create mode 100644 src/qonto_assistant/tenant_engine_client.py create mode 100644 tests/test_flex_auth_client.py create mode 100644 tests/test_live_authorization_gate.py create mode 100644 tests/test_tenant_engine_client.py diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 350c2f9..90f1275 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -207,6 +207,28 @@ That consumer-side write remains outside this repo. the current dogfood path remains `legacy_api_key` because that is the proven BINKY-WP-0005 header mode. +## Live authorization gate (flex-auth + tenant-engine) + +Off by default (no `QONTO_FLEX_AUTH_URL` set). When configured, every +capability call is gated on two live-checked facts before the internal +policy kernel runs (docs/SecurityPractice.md §4): + +1. **`flex-auth`**: `QONTO_FLEX_AUTH_URL` → a live `POST /v1/check` decision + on `finance.qonto.read` for the calling actor/tenant. See + `flex-auth/examples/qonto-assistant/` for the registered policy (rules + + tests, verified with `flex-auth test-policy`/`load-registry`/`check` and + a live `flex-auth serve` hit by this repo's actual `FlexAuthCheckClient`). +2. **`tenant-engine`** (optional, additive): `QONTO_TENANT_ENGINE_URL` → a + live `GET /tenants/{id}/roles/live` lookup, denying unless the tenant + currently holds one of `QONTO_TENANT_ENGINE_REQUIRED_ROLES` (default + `VEN,CUS`). Left unset, only the flex-auth check applies. + +Both clients fail closed: an unreachable flex-auth or tenant-engine denies, +it never grants. Deny reasons are `live_authz_denied` (flex-auth) and +`tenant_role_denied` (tenant-engine) in the audit log — neither counts +toward the deny-escalation lockout below, since a legitimate actor whose +tenant simply isn't provisioned yet isn't a probing signal. + ## Deny-escalation lockout On by default (`QONTO_DENY_ESCALATION_ENABLED=true`). An actor who triggers diff --git a/src/qonto_assistant/app.py b/src/qonto_assistant/app.py index 28d907a..6f95bfb 100644 --- a/src/qonto_assistant/app.py +++ b/src/qonto_assistant/app.py @@ -16,7 +16,9 @@ from qonto_assistant.auth import actor_claims_from_request from qonto_assistant.config import Settings from qonto_assistant.credentials import build_credential_provider from qonto_assistant.errors import QontoAssistantError, UpstreamError +from qonto_assistant.flex_auth_client import FlexAuthCheckClient from qonto_assistant.key_cape_auth import KeyCapeTokenVerifier +from qonto_assistant.live_authorization import LiveAuthorizationGate from qonto_assistant.mcp_auth import BearerTokenAuthMiddleware from qonto_assistant.mcp_server import create_mcp_server from qonto_assistant.policy import PolicyEngine @@ -24,6 +26,7 @@ from qonto_assistant.qonto_client import FixtureQontoClient, QontoClient from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter from qonto_assistant.security_watch import DenyEscalationTracker from qonto_assistant.service import CapabilityService +from qonto_assistant.tenant_engine_client import TenantEngineClient def create_app( @@ -193,6 +196,28 @@ def _build_service( if settings.deny_escalation_enabled else None ), + live_authorization_gate=_build_live_authorization_gate(settings), + ) + + +def _build_live_authorization_gate(settings: Settings) -> LiveAuthorizationGate | None: + if not settings.flex_auth_base_url: + return None + tenant_engine_client = ( + TenantEngineClient( + base_url=settings.tenant_engine_base_url, + timeout_seconds=settings.tenant_engine_timeout_seconds, + ) + if settings.tenant_engine_base_url + else None + ) + return LiveAuthorizationGate( + flex_auth_client=FlexAuthCheckClient( + base_url=settings.flex_auth_base_url, + timeout_seconds=settings.flex_auth_timeout_seconds, + ), + tenant_engine_client=tenant_engine_client, + required_tenant_roles=settings.tenant_engine_required_roles, ) diff --git a/src/qonto_assistant/config.py b/src/qonto_assistant/config.py index 23b752c..bbda29a 100644 --- a/src/qonto_assistant/config.py +++ b/src/qonto_assistant/config.py @@ -38,6 +38,11 @@ class Settings: key_cape_required: bool key_cape_cache_seconds: float key_cape_timeout_seconds: float + flex_auth_base_url: str | None + flex_auth_timeout_seconds: float + tenant_engine_base_url: str | None + tenant_engine_timeout_seconds: float + tenant_engine_required_roles: frozenset[str] credential_source: str openbao_path: str openbao_command: str @@ -86,6 +91,15 @@ class Settings: key_cape_required=os.getenv("QONTO_KEY_CAPE_REQUIRED", "false").lower() == "true", key_cape_cache_seconds=float(os.getenv("QONTO_KEY_CAPE_CACHE_SECONDS", "300")), key_cape_timeout_seconds=float(os.getenv("QONTO_KEY_CAPE_TIMEOUT_SECONDS", "5")), + flex_auth_base_url=os.getenv("QONTO_FLEX_AUTH_URL") or None, + flex_auth_timeout_seconds=float(os.getenv("QONTO_FLEX_AUTH_TIMEOUT_SECONDS", "3")), + tenant_engine_base_url=os.getenv("QONTO_TENANT_ENGINE_URL") or None, + tenant_engine_timeout_seconds=float(os.getenv("QONTO_TENANT_ENGINE_TIMEOUT_SECONDS", "3")), + tenant_engine_required_roles=frozenset( + role.strip() + for role in os.getenv("QONTO_TENANT_ENGINE_REQUIRED_ROLES", "VEN,CUS").split(",") + if role.strip() + ), credential_source=os.getenv("QONTO_CREDENTIAL_SOURCE", "env"), openbao_path=os.getenv("QONTO_OPENBAO_PATH", "tenants/binky/qonto-api"), openbao_command=os.getenv("QONTO_OPENBAO_COMMAND", "bao"), diff --git a/src/qonto_assistant/flex_auth_client.py b/src/qonto_assistant/flex_auth_client.py new file mode 100644 index 0000000..a00e086 --- /dev/null +++ b/src/qonto_assistant/flex_auth_client.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +import httpx + +# flex-auth's DecisionEnvelope schema (schemas/decision_envelope.schema.json) +# allows five effects; only "allow" authorizes anything. Pattern mirrors +# tenant-engine's own FlexAuthCheckClient (src/tenant_engine/flex_auth.py) -- +# see flex-auth/examples/qonto-assistant/ for this service's registration. +ALLOW_EFFECT = "allow" + +RESOURCE_SYSTEM = "qonto-assistant" +RESOURCE_TYPE = "finance-snapshot" +RESOURCE_ID = "finance-snapshot" +ACTION_FINANCE_READ = "finance.qonto.read" + + +class CheckRequest: + """Mirrors flex-auth/schemas/check_request.schema.json's shape.""" + + __slots__ = ("id", "tenant", "subject", "action", "resource", "context") + + def __init__( + self, + *, + request_id: str, + tenant: str, + subject_id: str, + subject_type: str, + action: str = ACTION_FINANCE_READ, + resource_id: str = RESOURCE_ID, + resource_type: str = RESOURCE_TYPE, + resource_system: str = RESOURCE_SYSTEM, + context: dict[str, Any] | None = None, + ) -> None: + self.id = request_id + self.tenant = tenant + self.subject = {"id": subject_id, "type": subject_type} + self.action = action + self.resource = {"id": resource_id, "type": resource_type, "system": resource_system} + self.context = context or {} + + def to_json(self) -> dict[str, Any]: + return { + "id": self.id, + "tenant": self.tenant, + "subject": self.subject, + "action": self.action, + "resource": self.resource, + "context": self.context, + } + + +class FlexAuthCheckClient: + """Client for flex-auth's POST /v1/check. + + Fail-closed by construction: every non-"allow" effect, every non-2xx + response, every malformed body, and every transport failure (timeout, + connection error) resolves to `False` from `is_allowed()`. Nothing + raises past this boundary -- a coarse authorization gate must never + fail open just because flex-auth is unreachable. + """ + + def __init__( + self, + *, + base_url: str, + timeout_seconds: float = 3.0, + transport: httpx.BaseTransport | None = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.timeout_seconds = timeout_seconds + self._client = httpx.Client( + base_url=self.base_url, + timeout=httpx.Timeout(timeout_seconds), + transport=transport, + ) + + def is_allowed(self, request: CheckRequest) -> bool: + try: + response = self._client.post("/v1/check", json=request.to_json()) + except httpx.HTTPError: + return False + + if response.status_code != 200: + return False + + try: + envelope = response.json() + except ValueError: + return False + + if not isinstance(envelope, dict): + return False + + return envelope.get("effect") == ALLOW_EFFECT + + def close(self) -> None: + self._client.close() + + +def new_request_id() -> str: + return f"check:{uuid4()}" diff --git a/src/qonto_assistant/live_authorization.py b/src/qonto_assistant/live_authorization.py new file mode 100644 index 0000000..558e14f --- /dev/null +++ b/src/qonto_assistant/live_authorization.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from qonto_assistant.contracts import ActorClaims +from qonto_assistant.flex_auth_client import CheckRequest, FlexAuthCheckClient, new_request_id +from qonto_assistant.tenant_engine_client import TenantEngineClient + +# Coarse subject typing for the flex-auth check request. qonto-assistant's +# registered policy (flex-auth/examples/qonto-assistant/policy_package.md) +# allows agent/human/service subject types identically for finance.qonto.read +# -- finer distinction (human vs. agent) would need key-cape's +# `principal_type` claim threaded through ActorClaims, which it is not yet. +# "service" is a safe, policy-neutral default until that lands. +DEFAULT_SUBJECT_TYPE = "service" + +DENY_LIVE_AUTHZ = "live_authz_denied" +DENY_TENANT_ROLE = "tenant_role_denied" + + +class LiveAuthorizationGate: + """Combines two independent, live-checked authorization facts, per + docs/SecurityPractice.md §4: + + 1. flex-auth's `finance.qonto.read` decision for this actor/tenant + (coarse: may this actor use the capability at all). + 2. tenant-engine's live capability-role lookup (does this tenant + currently hold a role this deployment requires, e.g. VEN/CUS). + + Both must pass. Either client fails closed on its own (see + FlexAuthCheckClient/TenantEngineClient), so an outage in either + dependency denies here rather than silently granting access. + """ + + def __init__( + self, + *, + flex_auth_client: FlexAuthCheckClient, + tenant_engine_client: TenantEngineClient | None, + required_tenant_roles: frozenset[str], + ) -> None: + self.flex_auth_client = flex_auth_client + self.tenant_engine_client = tenant_engine_client + self.required_tenant_roles = required_tenant_roles + + def check(self, claims: ActorClaims) -> str | None: + """Return None if allowed, else a deny reason string.""" + request = CheckRequest( + request_id=new_request_id(), + tenant=claims.tenant_id, + subject_id=claims.actor_id, + subject_type=DEFAULT_SUBJECT_TYPE, + ) + if not self.flex_auth_client.is_allowed(request): + return DENY_LIVE_AUTHZ + + if self.required_tenant_roles and self.tenant_engine_client is not None: + active_roles = self.tenant_engine_client.active_roles(claims.tenant_id) + if not (active_roles & self.required_tenant_roles): + return DENY_TENANT_ROLE + + return None + + def close(self) -> None: + self.flex_auth_client.close() + if self.tenant_engine_client is not None: + self.tenant_engine_client.close() diff --git a/src/qonto_assistant/service.py b/src/qonto_assistant/service.py index 567f313..92dc07b 100644 --- a/src/qonto_assistant/service.py +++ b/src/qonto_assistant/service.py @@ -7,8 +7,9 @@ from datetime import UTC, datetime from typing import Any from qonto_assistant.audit import AuditLogger, utc_now_iso -from qonto_assistant.contracts import ActorClaims, AuditEvent, CapabilityRequest, ProtocolName +from qonto_assistant.contracts import ActorClaims, AuditEvent, CapabilityRequest, PolicyDecision, ProtocolName from qonto_assistant.errors import InvalidRequestError, PolicyDeniedError, UpstreamError +from qonto_assistant.live_authorization import LiveAuthorizationGate from qonto_assistant.policy import PolicyEngine from qonto_assistant.qonto_client import QontoClientProtocol from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter @@ -25,6 +26,7 @@ class CapabilityService: rate_limiter: RateLimiter, concurrency_limiter: ConcurrencyLimiter, deny_escalation_tracker: DenyEscalationTracker | None = None, + live_authorization_gate: LiveAuthorizationGate | None = None, ) -> None: self.client = client self.policy = policy @@ -32,6 +34,7 @@ class CapabilityService: self.rate_limiter = rate_limiter self.concurrency_limiter = concurrency_limiter self.deny_escalation_tracker = deny_escalation_tracker + self.live_authorization_gate = live_authorization_gate def get_accounts( self, *, claims: ActorClaims, request_id: str, protocol: ProtocolName = "rest" @@ -162,6 +165,30 @@ class CapabilityService: ) raise + if self.live_authorization_gate is not None: + live_deny_reason = self.live_authorization_gate.check(claims) + if live_deny_reason is not None: + self._emit_audit( + request_id=request_id, + claims=claims, + capability_id=capability_id, + decision="deny", + deny_reason=live_deny_reason, + latency_ms=_latency_ms(started), + result_count=None, + qonto_http_status=None, + protocol=protocol, + ) + raise PolicyDeniedError( + PolicyDecision( + allowed=False, + capability_id=capability_id, + policy_version=self.policy.version, + reason=live_deny_reason, + request_args=dict(request_args), + ) + ) + decision = self.policy.decide(request) if not decision.allowed: if self.deny_escalation_tracker is not None: diff --git a/src/qonto_assistant/tenant_engine_client.py b/src/qonto_assistant/tenant_engine_client.py new file mode 100644 index 0000000..b01f468 --- /dev/null +++ b/src/qonto_assistant/tenant_engine_client.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import httpx + + +class TenantEngineClient: + """Client for tenant-engine's live capability-role lookup + (`GET /tenants/{tenant_id}/roles/live`). + + Fail-closed by construction, matching `FlexAuthCheckClient`: unreachable + tenant-engine, a non-200 response, or a malformed body all resolve to an + empty role set, never to "assume the tenant has the role." A tenant + whose plan lapsed must lose access on the next request, not whenever a + cache expires -- see docs/SecurityPractice.md §4. + """ + + def __init__( + self, + *, + base_url: str, + timeout_seconds: float = 3.0, + transport: httpx.BaseTransport | None = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.timeout_seconds = timeout_seconds + self._client = httpx.Client( + base_url=self.base_url, + timeout=httpx.Timeout(timeout_seconds), + transport=transport, + ) + + def active_roles(self, tenant_id: str) -> frozenset[str]: + try: + response = self._client.get(f"/tenants/{tenant_id}/roles/live") + except httpx.HTTPError: + return frozenset() + + if response.status_code != 200: + return frozenset() + + try: + payload = response.json() + except ValueError: + return frozenset() + + if not isinstance(payload, dict): + return frozenset() + + roles = payload.get("roles") + if not isinstance(roles, list): + return frozenset() + + return frozenset(str(role) for role in roles) + + def close(self) -> None: + self._client.close() diff --git a/tests/test_api.py b/tests/test_api.py index adfbcb8..4d83d63 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -44,6 +44,11 @@ def _settings() -> Settings: key_cape_required=False, key_cape_cache_seconds=300, key_cape_timeout_seconds=5, + flex_auth_base_url=None, + flex_auth_timeout_seconds=3, + tenant_engine_base_url=None, + tenant_engine_timeout_seconds=3, + tenant_engine_required_roles=frozenset({"VEN", "CUS"}), credential_source="env", openbao_path="tenants/binky/qonto-api", openbao_command="bao", diff --git a/tests/test_flex_auth_client.py b/tests/test_flex_auth_client.py new file mode 100644 index 0000000..0b293cb --- /dev/null +++ b/tests/test_flex_auth_client.py @@ -0,0 +1,92 @@ +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", + } diff --git a/tests/test_key_cape_auth.py b/tests/test_key_cape_auth.py index 161bf06..2f6f1b0 100644 --- a/tests/test_key_cape_auth.py +++ b/tests/test_key_cape_auth.py @@ -200,6 +200,11 @@ def _settings() -> Settings: key_cape_required=False, key_cape_cache_seconds=300, key_cape_timeout_seconds=5, + flex_auth_base_url=None, + flex_auth_timeout_seconds=3, + tenant_engine_base_url=None, + tenant_engine_timeout_seconds=3, + tenant_engine_required_roles=frozenset({"VEN", "CUS"}), credential_source="env", openbao_path="tenants/binky/qonto-api", openbao_command="bao", diff --git a/tests/test_live_authorization_gate.py b/tests/test_live_authorization_gate.py new file mode 100644 index 0000000..cace6db --- /dev/null +++ b/tests/test_live_authorization_gate.py @@ -0,0 +1,162 @@ +from pathlib import Path + +import httpx +import pytest + +from qonto_assistant.audit import AuditLogger +from qonto_assistant.contracts import ActorClaims +from qonto_assistant.errors import PolicyDeniedError +from qonto_assistant.flex_auth_client import FlexAuthCheckClient +from qonto_assistant.live_authorization import DENY_LIVE_AUTHZ, DENY_TENANT_ROLE, LiveAuthorizationGate +from qonto_assistant.policy import PolicyEngine +from qonto_assistant.qonto_client import FixtureQontoClient +from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter +from qonto_assistant.service import CapabilityService +from qonto_assistant.tenant_engine_client import TenantEngineClient + +POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml" +FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto" + + +def _claims(tenant_id: str = "tenant:friendly:binky") -> ActorClaims: + return ActorClaims(actor_id="agent-harness-binky", tenant_id=tenant_id, lane="green") + + +def _flex_auth_client(effect: str) -> FlexAuthCheckClient: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}}) + + return FlexAuthCheckClient(base_url="https://flex-auth.test", transport=httpx.MockTransport(handler)) + + +def _tenant_engine_client(roles: list[str]) -> TenantEngineClient: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"tenant_id": "tenant:friendly:binky", "roles": roles}) + + return TenantEngineClient(base_url="https://tenant-engine.test", transport=httpx.MockTransport(handler)) + + +# --- Gate unit tests ------------------------------------------------------------------- + + +def test_gate_allows_when_flex_auth_allows_and_role_matches() -> None: + gate = LiveAuthorizationGate( + flex_auth_client=_flex_auth_client("allow"), + tenant_engine_client=_tenant_engine_client(["VEN"]), + required_tenant_roles=frozenset({"VEN", "CUS"}), + ) + + assert gate.check(_claims()) is None + + +def test_gate_denies_when_flex_auth_denies() -> None: + gate = LiveAuthorizationGate( + flex_auth_client=_flex_auth_client("deny"), + tenant_engine_client=_tenant_engine_client(["VEN"]), + required_tenant_roles=frozenset({"VEN", "CUS"}), + ) + + assert gate.check(_claims()) == DENY_LIVE_AUTHZ + + +def test_gate_denies_when_tenant_lacks_required_role() -> None: + gate = LiveAuthorizationGate( + flex_auth_client=_flex_auth_client("allow"), + tenant_engine_client=_tenant_engine_client(["PLTF"]), # not VEN/CUS + required_tenant_roles=frozenset({"VEN", "CUS"}), + ) + + assert gate.check(_claims()) == DENY_TENANT_ROLE + + +def test_gate_skips_tenant_role_check_when_no_roles_required() -> None: + gate = LiveAuthorizationGate( + flex_auth_client=_flex_auth_client("allow"), + tenant_engine_client=_tenant_engine_client([]), + required_tenant_roles=frozenset(), + ) + + assert gate.check(_claims()) is None + + +def test_gate_skips_tenant_role_check_when_no_tenant_engine_configured() -> None: + gate = LiveAuthorizationGate( + flex_auth_client=_flex_auth_client("allow"), + tenant_engine_client=None, + required_tenant_roles=frozenset({"VEN"}), + ) + + assert gate.check(_claims()) is None + + +def test_gate_denies_when_tenant_engine_unreachable() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + gate = LiveAuthorizationGate( + flex_auth_client=_flex_auth_client("allow"), + tenant_engine_client=TenantEngineClient(base_url="https://tenant-engine.test", transport=httpx.MockTransport(handler)), + required_tenant_roles=frozenset({"VEN"}), + ) + + assert gate.check(_claims()) == DENY_TENANT_ROLE + + +# --- CapabilityService integration ----------------------------------------------------- + + +def _service(gate: LiveAuthorizationGate, events: list[dict[str, object]]) -> CapabilityService: + return CapabilityService( + client=FixtureQontoClient(fixture_dir=FIXTURE_DIR), + policy=PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False), + audit_logger=AuditLogger(sink=events.append), + rate_limiter=RateLimiter(limit=20, window_seconds=60), + concurrency_limiter=ConcurrencyLimiter(limit=4), + live_authorization_gate=gate, + ) + + +def test_service_denies_capability_call_when_gate_denies() -> None: + events: list[dict[str, object]] = [] + gate = LiveAuthorizationGate( + flex_auth_client=_flex_auth_client("deny"), + tenant_engine_client=None, + required_tenant_roles=frozenset(), + ) + service = _service(gate, events) + + with pytest.raises(PolicyDeniedError) as exc_info: + service.get_accounts(claims=_claims(), request_id="req-1", protocol="rest") + + assert exc_info.value.decision.reason == DENY_LIVE_AUTHZ + deny_events = [e for e in events if e.get("deny_reason") == DENY_LIVE_AUTHZ] + assert len(deny_events) == 1 + + +def test_service_allows_capability_call_when_gate_allows() -> None: + events: list[dict[str, object]] = [] + gate = LiveAuthorizationGate( + flex_auth_client=_flex_auth_client("allow"), + tenant_engine_client=_tenant_engine_client(["VEN"]), + required_tenant_roles=frozenset({"VEN", "CUS"}), + ) + service = _service(gate, events) + + payload = service.get_accounts(claims=_claims(), request_id="req-2", protocol="rest") + + assert "organization" in payload + + +def test_service_without_gate_configured_behaves_as_before() -> None: + events: list[dict[str, object]] = [] + service = CapabilityService( + client=FixtureQontoClient(fixture_dir=FIXTURE_DIR), + policy=PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False), + audit_logger=AuditLogger(sink=events.append), + rate_limiter=RateLimiter(limit=20, window_seconds=60), + concurrency_limiter=ConcurrencyLimiter(limit=4), + ) + + payload = service.get_accounts(claims=_claims(), request_id="req-3", protocol="rest") + + assert "organization" in payload diff --git a/tests/test_tenant_engine_client.py b/tests/test_tenant_engine_client.py new file mode 100644 index 0000000..8811f39 --- /dev/null +++ b/tests/test_tenant_engine_client.py @@ -0,0 +1,70 @@ +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" + return httpx.Response(200, json={"tenant_id": "tenant:friendly:binky", "roles": ["VEN", "CUS"]}) + + 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()