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:
tegwick 2026-07-24 00:19:04 +02:00
parent aa28ef353a
commit 1f0f979e36
12 changed files with 649 additions and 1 deletions

View file

@ -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,
)

View file

@ -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"),

View 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()}"

View 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()

View file

@ -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:

View 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()