qonto-assistant/docs/operator-runbook.md
tegwick b9349782f4 feat(audit): publish sequenced heartbeat and reconciliation evidence
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ec5-7e2b-7743-ac08-719e1b0f42e2
2026-09-05 01:39:48 +02:00

8.4 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
  • sequenced audit heartbeat and reconciliation evidence
  • 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:

  • make
  • python3 -m venv support (ensurepip missing)
  • 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: 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.

Audit deny-stream reconciliation

audit.deny is a low-volume, load-bearing observation class. Do not infer completeness from a minimum event rate. The service emits audit.heartbeat at startup, every QONTO_AUDIT_HEARTBEAT_INTERVAL_SECONDS while the process is active (default 86400), and best-effort at shutdown. Each request event and heartbeat carries stream_instance_id plus a monotonic stream_sequence.

For each instance, compare received audit.allow / audit.deny totals with the heartbeat's source_transition_counts and reject sequence gaps. Window counts describe transitions since the prior heartbeat. A quiet deny window has assertion: nothing-to-report; a non-quiet one has assertion: transitions-reported. Counters are process-local and reset only when stream_instance_id changes.

The authenticated diagnostic view is:

GET /v1/audit/reconciliation

It returns only stream identity, timestamps, sequence, and counts. It does not write State Hub, query an observer, include actor/bank data, or increment the stream it describes. The exact contract is declared in specs/audit-emission-cadence.yaml.

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.