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>
This commit is contained in:
parent
aa28ef353a
commit
1f0f979e36
12 changed files with 649 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
105
src/qonto_assistant/flex_auth_client.py
Normal file
105
src/qonto_assistant/flex_auth_client.py
Normal file
|
|
@ -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()}"
|
||||
65
src/qonto_assistant/live_authorization.py
Normal file
65
src/qonto_assistant/live_authorization.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
56
src/qonto_assistant/tenant_engine_client.py
Normal file
56
src/qonto_assistant/tenant_engine_client.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
92
tests/test_flex_auth_client.py
Normal file
92
tests/test_flex_auth_client.py
Normal file
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
162
tests/test_live_authorization_gate.py
Normal file
162
tests/test_live_authorization_gate.py
Normal file
|
|
@ -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
|
||||
70
tests/test_tenant_engine_client.py
Normal file
70
tests/test_tenant_engine_client.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue