Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ec5-7e2b-7743-ac08-719e1b0f42e2
270 lines
8.4 KiB
Markdown
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`.
|