qonto-assistant/src/qonto_assistant/app.py

234 lines
8.8 KiB
Python
Raw Normal View History

from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from contextlib import AsyncExitStack, asynccontextmanager, suppress
import logging
from uuid import uuid4
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.concurrency import run_in_threadpool
from qonto_assistant import __version__
from qonto_assistant.audit import AuditLogger
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
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
from qonto_assistant.flex_auth_client import FlexAuthCheckClient
QONTO-WP-0004-T03: verify key-cape IAM Profile tokens Replaces the interim shared-secret bearer token's role as the identity boundary with real key-cape JWKS-based verification, closing the gap docs/mcp-integration.md called out explicitly ("no OIDC issuer exists in this fleet yet") -- key-cape's /jwks is a standard RS256 endpoint and needed no key-cape-side work to consume. KeyCapeTokenVerifier fetches and caches signing keys over httpx (matching FlexAuthCheckClient's pattern elsewhere in this codebase), validates iss/aud/exp and the IAM Profile v0.3 required claims, and derives ActorClaims from the token (tenant, scopes, and a lane inferred from the roles claim). Wired into auth.actor_claims_from_headers, the single seam both REST and MCP already used -- a verified bearer token now takes precedence over self-asserted X-Actor-* headers, and can be made mandatory via QONTO_KEY_CAPE_REQUIRED once real tokens are issued to callers. Off by default (no QONTO_KEY_CAPE_JWKS_URL set) so existing deployments are unaffected until configured. The QONTO_ASSISTANT_MCP_TOKEN shared secret remains as a documented local-dev/legacy fallback, not the auth boundary going forward. Verified: 13 new tests (test_key_cape_auth.py) covering valid/expired/ wrong-audience/wrong-issuer/missing-claim/unknown-key/rotated-key tokens plus the auth.py precedence and required-vs-optional integration paths, using a real generated RSA keypair and JWKS served over httpx.MockTransport. Full suite -> 52 passed; REST and MCP smokes both still pass against fixtures; compileall clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:10:41 +02:00
from qonto_assistant.key_cape_auth import KeyCapeTokenVerifier
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
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
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
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
from qonto_assistant.tenant_engine_client import TenantEngineClient
def create_app(
*,
settings: Settings | None = None,
service: CapabilityService | None = None,
audit_logger: AuditLogger | None = None,
rate_limiter: RateLimiter | None = None,
concurrency_limiter: ConcurrencyLimiter | None = None,
QONTO-WP-0004-T03: verify key-cape IAM Profile tokens Replaces the interim shared-secret bearer token's role as the identity boundary with real key-cape JWKS-based verification, closing the gap docs/mcp-integration.md called out explicitly ("no OIDC issuer exists in this fleet yet") -- key-cape's /jwks is a standard RS256 endpoint and needed no key-cape-side work to consume. KeyCapeTokenVerifier fetches and caches signing keys over httpx (matching FlexAuthCheckClient's pattern elsewhere in this codebase), validates iss/aud/exp and the IAM Profile v0.3 required claims, and derives ActorClaims from the token (tenant, scopes, and a lane inferred from the roles claim). Wired into auth.actor_claims_from_headers, the single seam both REST and MCP already used -- a verified bearer token now takes precedence over self-asserted X-Actor-* headers, and can be made mandatory via QONTO_KEY_CAPE_REQUIRED once real tokens are issued to callers. Off by default (no QONTO_KEY_CAPE_JWKS_URL set) so existing deployments are unaffected until configured. The QONTO_ASSISTANT_MCP_TOKEN shared secret remains as a documented local-dev/legacy fallback, not the auth boundary going forward. Verified: 13 new tests (test_key_cape_auth.py) covering valid/expired/ wrong-audience/wrong-issuer/missing-claim/unknown-key/rotated-key tokens plus the auth.py precedence and required-vs-optional integration paths, using a real generated RSA keypair and JWKS served over httpx.MockTransport. Full suite -> 52 passed; REST and MCP smokes both still pass against fixtures; compileall clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:10:41 +02:00
key_cape_verifier: KeyCapeTokenVerifier | None = None,
) -> FastAPI:
settings = settings or Settings.from_env()
audit_logger = audit_logger or AuditLogger()
service = service or _build_service(
settings=settings,
audit_logger=audit_logger,
rate_limiter=rate_limiter,
concurrency_limiter=concurrency_limiter,
)
QONTO-WP-0004-T03: verify key-cape IAM Profile tokens Replaces the interim shared-secret bearer token's role as the identity boundary with real key-cape JWKS-based verification, closing the gap docs/mcp-integration.md called out explicitly ("no OIDC issuer exists in this fleet yet") -- key-cape's /jwks is a standard RS256 endpoint and needed no key-cape-side work to consume. KeyCapeTokenVerifier fetches and caches signing keys over httpx (matching FlexAuthCheckClient's pattern elsewhere in this codebase), validates iss/aud/exp and the IAM Profile v0.3 required claims, and derives ActorClaims from the token (tenant, scopes, and a lane inferred from the roles claim). Wired into auth.actor_claims_from_headers, the single seam both REST and MCP already used -- a verified bearer token now takes precedence over self-asserted X-Actor-* headers, and can be made mandatory via QONTO_KEY_CAPE_REQUIRED once real tokens are issued to callers. Off by default (no QONTO_KEY_CAPE_JWKS_URL set) so existing deployments are unaffected until configured. The QONTO_ASSISTANT_MCP_TOKEN shared secret remains as a documented local-dev/legacy fallback, not the auth boundary going forward. Verified: 13 new tests (test_key_cape_auth.py) covering valid/expired/ wrong-audience/wrong-issuer/missing-claim/unknown-key/rotated-key tokens plus the auth.py precedence and required-vs-optional integration paths, using a real generated RSA keypair and JWKS served over httpx.MockTransport. Full suite -> 52 passed; REST and MCP smokes both still pass against fixtures; compileall clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:10:41 +02:00
if key_cape_verifier is None and settings.key_cape_jwks_url:
key_cape_verifier = KeyCapeTokenVerifier(
jwks_url=settings.key_cape_jwks_url,
issuer=settings.key_cape_issuer,
audience=settings.key_cape_audience,
required=settings.key_cape_required,
default_lane=settings.default_actor_lane,
timeout_seconds=settings.key_cape_timeout_seconds,
cache_seconds=settings.key_cape_cache_seconds,
)
QONTO-WP-0004-T03: verify key-cape IAM Profile tokens Replaces the interim shared-secret bearer token's role as the identity boundary with real key-cape JWKS-based verification, closing the gap docs/mcp-integration.md called out explicitly ("no OIDC issuer exists in this fleet yet") -- key-cape's /jwks is a standard RS256 endpoint and needed no key-cape-side work to consume. KeyCapeTokenVerifier fetches and caches signing keys over httpx (matching FlexAuthCheckClient's pattern elsewhere in this codebase), validates iss/aud/exp and the IAM Profile v0.3 required claims, and derives ActorClaims from the token (tenant, scopes, and a lane inferred from the roles claim). Wired into auth.actor_claims_from_headers, the single seam both REST and MCP already used -- a verified bearer token now takes precedence over self-asserted X-Actor-* headers, and can be made mandatory via QONTO_KEY_CAPE_REQUIRED once real tokens are issued to callers. Off by default (no QONTO_KEY_CAPE_JWKS_URL set) so existing deployments are unaffected until configured. The QONTO_ASSISTANT_MCP_TOKEN shared secret remains as a documented local-dev/legacy fallback, not the auth boundary going forward. Verified: 13 new tests (test_key_cape_auth.py) covering valid/expired/ wrong-audience/wrong-issuer/missing-claim/unknown-key/rotated-key tokens plus the auth.py precedence and required-vs-optional integration paths, using a real generated RSA keypair and JWKS served over httpx.MockTransport. Full suite -> 52 passed; REST and MCP smokes both still pass against fixtures; compileall clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:10:41 +02:00
mcp_server = create_mcp_server(settings=settings, service=service, key_cape_verifier=key_cape_verifier)
mcp_app = mcp_server.streamable_http_app()
if settings.mcp_auth_token:
mcp_app.add_middleware(BearerTokenAuthMiddleware, token=settings.mcp_auth_token)
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
yield
with suppress(Exception):
service.client.close()
app = FastAPI(title="qonto-assistant", version=__version__, lifespan=lifespan)
app.state.settings = settings
app.state.service = service
app.mount("/mcp", mcp_app)
@app.exception_handler(QontoAssistantError)
def handle_qonto_error(_: Request, exc: QontoAssistantError) -> JSONResponse:
body = {"error_code": exc.error_code, "detail": exc.message}
if isinstance(exc, UpstreamError) and exc.upstream_status is not None:
body["upstream_status"] = exc.upstream_status
return JSONResponse(status_code=exc.status_code, content=body)
@app.get("/v1/health")
async def health() -> dict[str, str]:
return {
"status": "ok",
"service": settings.service_name,
"version": __version__,
"policy_file": str(settings.policy_file),
}
@app.get("/v1/accounts")
async def get_accounts(request: Request) -> JSONResponse:
QONTO-WP-0004-T03: verify key-cape IAM Profile tokens Replaces the interim shared-secret bearer token's role as the identity boundary with real key-cape JWKS-based verification, closing the gap docs/mcp-integration.md called out explicitly ("no OIDC issuer exists in this fleet yet") -- key-cape's /jwks is a standard RS256 endpoint and needed no key-cape-side work to consume. KeyCapeTokenVerifier fetches and caches signing keys over httpx (matching FlexAuthCheckClient's pattern elsewhere in this codebase), validates iss/aud/exp and the IAM Profile v0.3 required claims, and derives ActorClaims from the token (tenant, scopes, and a lane inferred from the roles claim). Wired into auth.actor_claims_from_headers, the single seam both REST and MCP already used -- a verified bearer token now takes precedence over self-asserted X-Actor-* headers, and can be made mandatory via QONTO_KEY_CAPE_REQUIRED once real tokens are issued to callers. Off by default (no QONTO_KEY_CAPE_JWKS_URL set) so existing deployments are unaffected until configured. The QONTO_ASSISTANT_MCP_TOKEN shared secret remains as a documented local-dev/legacy fallback, not the auth boundary going forward. Verified: 13 new tests (test_key_cape_auth.py) covering valid/expired/ wrong-audience/wrong-issuer/missing-claim/unknown-key/rotated-key tokens plus the auth.py precedence and required-vs-optional integration paths, using a real generated RSA keypair and JWKS served over httpx.MockTransport. Full suite -> 52 passed; REST and MCP smokes both still pass against fixtures; compileall clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:10:41 +02:00
claims = actor_claims_from_request(request, settings, key_cape_verifier=key_cape_verifier)
request_id = _request_id(request)
payload = await run_in_threadpool(service.get_accounts, claims=claims, request_id=request_id)
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
@app.get("/v1/transactions")
async def get_transactions(
request: Request,
account_slug: str | None = None,
page: int = 1,
page_size: int = 50,
window_days: int = 31,
status: str | None = "completed",
side: str | None = None,
) -> JSONResponse:
QONTO-WP-0004-T03: verify key-cape IAM Profile tokens Replaces the interim shared-secret bearer token's role as the identity boundary with real key-cape JWKS-based verification, closing the gap docs/mcp-integration.md called out explicitly ("no OIDC issuer exists in this fleet yet") -- key-cape's /jwks is a standard RS256 endpoint and needed no key-cape-side work to consume. KeyCapeTokenVerifier fetches and caches signing keys over httpx (matching FlexAuthCheckClient's pattern elsewhere in this codebase), validates iss/aud/exp and the IAM Profile v0.3 required claims, and derives ActorClaims from the token (tenant, scopes, and a lane inferred from the roles claim). Wired into auth.actor_claims_from_headers, the single seam both REST and MCP already used -- a verified bearer token now takes precedence over self-asserted X-Actor-* headers, and can be made mandatory via QONTO_KEY_CAPE_REQUIRED once real tokens are issued to callers. Off by default (no QONTO_KEY_CAPE_JWKS_URL set) so existing deployments are unaffected until configured. The QONTO_ASSISTANT_MCP_TOKEN shared secret remains as a documented local-dev/legacy fallback, not the auth boundary going forward. Verified: 13 new tests (test_key_cape_auth.py) covering valid/expired/ wrong-audience/wrong-issuer/missing-claim/unknown-key/rotated-key tokens plus the auth.py precedence and required-vs-optional integration paths, using a real generated RSA keypair and JWKS served over httpx.MockTransport. Full suite -> 52 passed; REST and MCP smokes both still pass against fixtures; compileall clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:10:41 +02:00
claims = actor_claims_from_request(request, settings, key_cape_verifier=key_cape_verifier)
request_id = _request_id(request)
payload = await run_in_threadpool(
service.list_transactions,
claims=claims,
request_id=request_id,
account_slug=account_slug,
page=page,
page_size=page_size,
window_days=window_days,
status=status,
side=side,
)
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
@app.get("/v1/snapshot")
async def get_snapshot(
request: Request,
window_days: int = 31,
page_size: int = 50,
) -> JSONResponse:
QONTO-WP-0004-T03: verify key-cape IAM Profile tokens Replaces the interim shared-secret bearer token's role as the identity boundary with real key-cape JWKS-based verification, closing the gap docs/mcp-integration.md called out explicitly ("no OIDC issuer exists in this fleet yet") -- key-cape's /jwks is a standard RS256 endpoint and needed no key-cape-side work to consume. KeyCapeTokenVerifier fetches and caches signing keys over httpx (matching FlexAuthCheckClient's pattern elsewhere in this codebase), validates iss/aud/exp and the IAM Profile v0.3 required claims, and derives ActorClaims from the token (tenant, scopes, and a lane inferred from the roles claim). Wired into auth.actor_claims_from_headers, the single seam both REST and MCP already used -- a verified bearer token now takes precedence over self-asserted X-Actor-* headers, and can be made mandatory via QONTO_KEY_CAPE_REQUIRED once real tokens are issued to callers. Off by default (no QONTO_KEY_CAPE_JWKS_URL set) so existing deployments are unaffected until configured. The QONTO_ASSISTANT_MCP_TOKEN shared secret remains as a documented local-dev/legacy fallback, not the auth boundary going forward. Verified: 13 new tests (test_key_cape_auth.py) covering valid/expired/ wrong-audience/wrong-issuer/missing-claim/unknown-key/rotated-key tokens plus the auth.py precedence and required-vs-optional integration paths, using a real generated RSA keypair and JWKS served over httpx.MockTransport. Full suite -> 52 passed; REST and MCP smokes both still pass against fixtures; compileall clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:10:41 +02:00
claims = actor_claims_from_request(request, settings, key_cape_verifier=key_cape_verifier)
request_id = _request_id(request)
payload = await run_in_threadpool(
service.get_snapshot,
claims=claims,
request_id=request_id,
window_days=window_days,
page_size=page_size,
)
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
return app
def _request_id(request: Request) -> str:
existing = getattr(request.state, "request_id", None)
if existing:
return existing
request.state.request_id = request.headers.get("x-request-id", str(uuid4()))
return request.state.request_id
def _build_service(
*,
settings: Settings,
audit_logger: AuditLogger,
rate_limiter: RateLimiter | None,
concurrency_limiter: ConcurrencyLimiter | None,
) -> CapabilityService:
policy = PolicyEngine.from_file(
settings.policy_file,
required_scope=settings.required_scope,
enforce_scope=settings.enforce_scope,
)
if settings.qonto_fixture_dir is not None:
client = FixtureQontoClient(fixture_dir=settings.qonto_fixture_dir)
else:
credential_provider = build_credential_provider(settings)
client = QontoClient(
base_url=settings.qonto_base_url,
organization_path=settings.qonto_organization_path,
transactions_path=settings.qonto_transactions_path,
auth_mode=settings.qonto_auth_mode,
timeout_seconds=settings.qonto_timeout_seconds,
max_retries=settings.qonto_max_retries,
credential_provider=credential_provider,
)
return CapabilityService(
client=client,
policy=policy,
audit_logger=audit_logger,
rate_limiter=rate_limiter
or RateLimiter(
limit=settings.rate_limit_requests,
window_seconds=settings.rate_limit_window_seconds,
),
concurrency_limiter=concurrency_limiter
or ConcurrencyLimiter(limit=settings.max_concurrency),
deny_escalation_tracker=(
DenyEscalationTracker(
threshold=settings.deny_escalation_threshold,
window_seconds=settings.deny_escalation_window_seconds,
lockout_seconds=settings.deny_escalation_lockout_seconds,
)
if settings.deny_escalation_enabled
else None
),
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
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,
)
def main() -> None:
settings = Settings.from_env()
logging.basicConfig(level=logging.INFO, format="%(message)s")
uvicorn.run(
"qonto_assistant.app:create_app",
factory=True,
host=settings.host,
port=settings.port,
reload=False,
)