qonto-assistant/src/qonto_assistant/live_authorization.py
tegwick 1f0f979e36 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

65 lines
2.6 KiB
Python

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