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>
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>
Design doc for hardening qonto-assistant before deployment to
railiance01: this is the first fleet service that must be
internet-reachable (external harness clients, not just in-cluster
jobs) while holding a real bank credential. Covers identity (key-cape
in place of the interim bearer token), authorization (finance.qonto.read
in flex-auth + tenant-engine capability roles instead of the
hardcoded default_tenant_id), network exposure (facade-only internet
address), isolation profile, and a Kings Guard mapping (the existing
audit stream is already Immune-Observation-shaped; nothing to rebuild
later).
Ships one concrete, dependency-free piece of that design now:
DenyEscalationTracker locks out an actor who repeatedly triggers
arg_constraint/credential_exfil denies within a short window, closing
the gap where a probing client could retry indefinitely at whatever
rate the existing rate limiter otherwise allows. Wired through
CapabilityService, on by default, configurable via
QONTO_DENY_ESCALATION_* env vars. Ordinary denies (authz_denied,
tenant_scope) never count toward it.
Also adds specs/security-genome.yaml (kings-guard's genome-record
shape, populated now so no rework is needed once a consumer exists).
Verified: pytest -> 39 passed (8 new); REST and MCP smoke scripts both
pass against fixtures; compileall clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CapabilityService._emit_audit was already the single audit call site for
both transports since T02, but nothing failed if a future change diverged
one transport's shape. Add tests/test_audit_parity.py: same capability
called through protocol="rest" and protocol="mcp" (allow path and deny
path) must produce identical audit events except request_id/timestamp/
latency_ms (expected to vary) and protocol (expected to differ). Also pins
down that no audit event ever contains a secret-shaped field name.
Confirmed via grep: no State Hub coupling anywhere in src/qonto_assistant/
-- the only audit sink is AuditLogger, so there's no per-call hot-path
write to accidentally wire up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Gate /mcp with a shared-secret bearer token (QONTO_ASSISTANT_MCP_TOKEN,
mcp_auth.py::BearerTokenAuthMiddleware, constant-time compare, REST
untouched) since no OIDC issuer exists in this fleet yet -- pointing
FastMCP's OAuth Protected Resource flow at a non-existent issuer would be
worse than not having it. This token is a service credential, never a bank
credential; per-actor identity stays the existing X-Actor-* convention.
Add docs/mcp-integration.md: tool catalog, the two-layer auth model (workload
auth today vs. deferred OIDC target), and one shared {"mcpServers": {...}}
client config snippet (url + headers) usable across Claude Code, Claude
Desktop, Cursor, and Codex/Grok-style harnesses.
Verified live using only that snippet: unauthenticated and wrong-token
requests get 401 before reaching any tool; a request built from the
snippet's URL + headers lists tools and calls qonto_org_summary
successfully against the fixture-backed server.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add qonto_org_summary, qonto_list_transactions, and qonto_cost_run_rate_hints
MCP tools, all routed through CapabilityService with protocol="mcp" -- same
PolicyEngine.decide() path as REST, same deny-reason vocabulary. Skip
snapshot_bundle as an MCP tool (REST already covers the composite read; not
a separate privilege).
CapabilityService now threads protocol through _execute/_emit_audit instead
of hardcoding "rest". cost_run_rate_hints gets its own service method since
it's an independent policy capability, not only a snapshot sub-field.
Actor identity reuses REST's X-Actor-* header convention via a shared
auth.actor_claims_from_headers(), read from the MCP Context's request when
present. Fixed streamable_http_path defaulting to "/mcp", which doubled to
"/mcp/mcp" once mounted under the "/mcp" prefix.
Verified end-to-end with the mcp SDK's streamablehttp_client against the
live fixture-backed server: tool list, allow/deny paths, and X-Actor-ID
flowing through to the audit log exactly like REST.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add a streamable-HTTP MCP adapter (mcp_server.py, official FastMCP SDK)
mounted at /mcp in the existing FastAPI app, with a combined lifespan so
the MCP session manager starts/stops with the service. Ships one smoke
tool (qonto_ping, no bank call) — real capability tools land in T02.
Prove REST and MCP hit the identical policy path: PolicyEngine.decide()
never branches on request.protocol, verified by a parametrized test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements QONTO-WP-0002 (policy-gated Qonto REST service with audit
logging, rate limiting, and credential handling) and the ADHOC-2026-07-21
follow-up (fixture-backed local smoke mode, repo classification metadata).
Marks QONTO-WP-0001/0002 and the ad-hoc workplan finished, and regenerates
WORK-RECORDS.md and the ADHOC workplan's state_hub_workstream_id via
fix-consistency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>