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>
65 lines
2.6 KiB
Python
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()
|