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>
7.2 KiB
Qonto Assistant Operator Runbook
What this Phase 1 service does
qonto-assistant is the read-only REST surface for governed Qonto access.
Phase 1 ships:
- policy-gated
GET /v1/accounts - policy-gated
GET /v1/transactions - policy-gated
GET /v1/snapshot - structured audit events without secrets
- env-backed or OpenBao-CLI-backed credential loading
Spend, transfer, card, invoicing, payment-link, and other volume-cost actions remain denied by policy.
Preferred local workflow
If make and python3 -m venv are available:
make install-dev
make test
make run
The API then listens on http://127.0.0.1:8080.
Verified fallback on this workstation
This workstation currently lacks:
makepython3 -m venvsupport (ensurepipmissing)python3 -m pip
Use an existing fleet virtualenv that already contains FastAPI/httpx/pytest:
PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest
python3 -m compileall src tests
This fallback was used to verify the current implementation.
Credential sources
Option A: env-injected credentials
Provide either:
API_USER+API_KEY- or
QONTO_ORGANIZATION_ID+QONTO_API_KEY
Example:
export API_USER='...'
export API_KEY='...'
Option B: OpenBao CLI fetch inside the service
Set:
export QONTO_CREDENTIAL_SOURCE=bao-cli
export QONTO_OPENBAO_PATH=tenants/binky/qonto-api
export QONTO_OPENBAO_COMMAND=bao
The service then shells out to bao kv get -field=... and caches the
credentials in memory for a short TTL.
Start the API
Preferred:
make run
Fallback:
PYTHONPATH=src ../state-hub/.venv/bin/python -m qonto_assistant.main
Fixture-backed local mode
For local smoke work without real bank credentials:
export QONTO_FIXTURE_DIR=tests/fixtures/qonto
PYTHONPATH=src ../state-hub/.venv/bin/python -m qonto_assistant.main
In this mode the service serves canned Qonto organization and transaction
payloads from tests/fixtures/qonto/.
One-command HTTP smoke
../state-hub/.venv/bin/python scripts/smoke_rest_api.py \
--python ../state-hub/.venv/bin/python
This starts the service on a random local port against the fixture payloads,
checks /v1/health, /v1/accounts, a recent 31-day snapshot, and a wider
90-day cost-review snapshot, then shuts the process down.
One-command MCP smoke
../state-hub/.venv/bin/python scripts/smoke_mcp.py \
--python ../state-hub/.venv/bin/python
Same shape as the REST smoke: starts the service on a random local port
against the fixture payloads, this time with QONTO_ASSISTANT_MCP_TOKEN set
to a freshly generated token so the auth layer (docs/mcp-integration.md) is
exercised too, not bypassed. Connects with the mcp SDK's
streamablehttp_client, lists tools, calls qonto_ping,
qonto_org_summary, qonto_list_transactions, and
qonto_cost_run_rate_hints, then confirms an out-of-catalog tool name
(qonto_transfer_funds, never registered) comes back as a normal
isError result rather than a crash or a policy bypass. No real Qonto
credentials involved.
Example calls
Minimal local call:
python3 - <<'PY'
import json
import urllib.request
req = urllib.request.Request(
"http://127.0.0.1:8080/v1/accounts",
headers={"X-Actor-ID": "local-operator", "X-Tenant-ID": "binky"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
print(json.dumps(json.load(resp), indent=2))
PY
Transactions view:
python3 - <<'PY'
import json
import urllib.request
req = urllib.request.Request(
"http://127.0.0.1:8080/v1/transactions?page_size=50&window_days=31",
headers={"X-Actor-ID": "finance-steward", "X-Tenant-ID": "binky"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
print(json.dumps(json.load(resp), indent=2))
PY
Snapshot for CostRunRate refresh:
python3 - <<'PY'
import json
import urllib.request
req = urllib.request.Request(
"http://127.0.0.1:8080/v1/snapshot?window_days=90&page_size=50",
headers={"X-Actor-ID": "finance-steward", "X-Tenant-ID": "binky"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
print(json.dumps(json.load(resp), indent=2))
PY
Use window_days=31 for a recent-activity view. Use window_days=90 or 93
when recurring fixed-cost hints are required.
MCP surface
Phase 2 mounts a streamable-HTTP MCP adapter at /mcp on this same process,
sharing the policy kernel and audit layer above. See
docs/mcp-integration.md for the tool catalog, the auth model
(QONTO_ASSISTANT_MCP_TOKEN), and the shared client config snippet.
CostRunRate refresh path
binky-control should consume GET /v1/snapshot and extract:
- redacted organization/account summary
- recent transactions
- recurring debit hints for fixed-cost review
The repo does not write directly into binky-control/finance/CostRunRate.md.
That consumer-side write remains outside this repo.
Safety notes
- Never print or commit
API_KEY. - Prefer
X-Tenant-ID: binkyexplicitly even in single-tenant dogfood. - Audit output is metadata-only; account identifiers stay redacted by default.
- The service supports a
bearerauth mode for future upstream evolution, but the current dogfood path remainslegacy_api_keybecause that is the proven BINKY-WP-0005 header mode.
Live authorization gate (flex-auth + tenant-engine)
Off by default (no QONTO_FLEX_AUTH_URL set). When configured, every
capability call is gated on two live-checked facts before the internal
policy kernel runs (docs/SecurityPractice.md §4):
flex-auth:QONTO_FLEX_AUTH_URL→ a livePOST /v1/checkdecision onfinance.qonto.readfor the calling actor/tenant. Seeflex-auth/examples/qonto-assistant/for the registered policy (rules + tests, verified withflex-auth test-policy/load-registry/checkand a liveflex-auth servehit by this repo's actualFlexAuthCheckClient).tenant-engine(optional, additive):QONTO_TENANT_ENGINE_URL→ a liveGET /tenants/{id}/roles/livelookup, denying unless the tenant currently holds one ofQONTO_TENANT_ENGINE_REQUIRED_ROLES(defaultVEN,CUS). Left unset, only the flex-auth check applies.
Both clients fail closed: an unreachable flex-auth or tenant-engine denies,
it never grants. Deny reasons are live_authz_denied (flex-auth) and
tenant_role_denied (tenant-engine) in the audit log — neither counts
toward the deny-escalation lockout below, since a legitimate actor whose
tenant simply isn't provisioned yet isn't a probing signal.
Deny-escalation lockout
On by default (QONTO_DENY_ESCALATION_ENABLED=true). An actor who triggers
arg_constraint or credential_exfil policy denials QONTO_DENY_ESCALATION_THRESHOLD
times (default 3) within QONTO_DENY_ESCALATION_WINDOW_SECONDS (default 60s)
is locked out for QONTO_DENY_ESCALATION_LOCKOUT_SECONDS (default 300s) —
every request from that actor is rejected with actor_locked_out before the
policy kernel is even consulted, regardless of which capability they call
next. Ordinary denies (authz_denied, tenant_scope, unknown_capability)
never count toward this — only the two reason classes that indicate
probing/exfiltration rather than a client mistake. See
docs/SecurityPractice.md §9.3 and src/qonto_assistant/security_watch.py.