# 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: ```bash 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: - `make` - `python3 -m venv` support (`ensurepip` missing) - `python3 -m pip` Use an existing fleet virtualenv that already contains FastAPI/httpx/pytest: ```bash 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: ```bash export API_USER='...' export API_KEY='...' ``` ### Option B: OpenBao CLI fetch inside the service Set: ```bash 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: ```bash make run ``` Fallback: ```bash PYTHONPATH=src ../state-hub/.venv/bin/python -m qonto_assistant.main ``` ## Fixture-backed local mode For local smoke work without real bank credentials: ```bash 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 ```bash ../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 ```bash ../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: ```bash 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: ```bash 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: ```bash 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: binky` explicitly even in single-tenant dogfood. - Audit output is metadata-only; account identifiers stay redacted by default. - The service supports a `bearer` auth mode for future upstream evolution, but the current dogfood path remains `legacy_api_key` because 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): 1. **`flex-auth`**: `QONTO_FLEX_AUTH_URL` → a live `POST /v1/check` decision on `finance.qonto.read` for the calling actor/tenant. See `flex-auth/examples/qonto-assistant/` for the registered policy (rules + tests, verified with `flex-auth test-policy`/`load-registry`/`check` and a live `flex-auth serve` hit by this repo's actual `FlexAuthCheckClient`). 2. **`tenant-engine`** (optional, additive): `QONTO_TENANT_ENGINE_URL` → a live `GET /tenants/{id}/roles/live` lookup, denying unless the tenant currently holds one of `QONTO_TENANT_ENGINE_REQUIRED_ROLES` (default `VEN,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`.