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

270 lines
8.4 KiB
Markdown

# 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:
```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.
## 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:
```text
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`.