2026-07-22 21:21:05 +02:00
|
|
|
# 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.
|
|
|
|
|
|
2026-07-23 11:04:39 +02:00
|
|
|
## 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.
|
|
|
|
|
|
2026-07-22 21:21:05 +02:00
|
|
|
## 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.
|
|
|
|
|
|
2026-07-23 09:41:25 +02:00
|
|
|
## 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.
|
|
|
|
|
|
2026-07-22 21:21:05 +02:00
|
|
|
## 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.
|
Add SecurityPractice.md, Security Genome record, and deny-escalation lockout
Design doc for hardening qonto-assistant before deployment to
railiance01: this is the first fleet service that must be
internet-reachable (external harness clients, not just in-cluster
jobs) while holding a real bank credential. Covers identity (key-cape
in place of the interim bearer token), authorization (finance.qonto.read
in flex-auth + tenant-engine capability roles instead of the
hardcoded default_tenant_id), network exposure (facade-only internet
address), isolation profile, and a Kings Guard mapping (the existing
audit stream is already Immune-Observation-shaped; nothing to rebuild
later).
Ships one concrete, dependency-free piece of that design now:
DenyEscalationTracker locks out an actor who repeatedly triggers
arg_constraint/credential_exfil denies within a short window, closing
the gap where a probing client could retry indefinitely at whatever
rate the existing rate limiter otherwise allows. Wired through
CapabilityService, on by default, configurable via
QONTO_DENY_ESCALATION_* env vars. Ordinary denies (authz_denied,
tenant_scope) never count toward it.
Also adds specs/security-genome.yaml (kings-guard's genome-record
shape, populated now so no rework is needed once a consumer exists).
Verified: pytest -> 39 passed (8 new); REST and MCP smoke scripts both
pass against fixtures; compileall clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:59:06 +02:00
|
|
|
|
QONTO-WP-0004-T04: live flex-auth + tenant-engine authorization gate
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>
2026-07-24 00:19:04 +02:00
|
|
|
## 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.
|
|
|
|
|
|
Add SecurityPractice.md, Security Genome record, and deny-escalation lockout
Design doc for hardening qonto-assistant before deployment to
railiance01: this is the first fleet service that must be
internet-reachable (external harness clients, not just in-cluster
jobs) while holding a real bank credential. Covers identity (key-cape
in place of the interim bearer token), authorization (finance.qonto.read
in flex-auth + tenant-engine capability roles instead of the
hardcoded default_tenant_id), network exposure (facade-only internet
address), isolation profile, and a Kings Guard mapping (the existing
audit stream is already Immune-Observation-shaped; nothing to rebuild
later).
Ships one concrete, dependency-free piece of that design now:
DenyEscalationTracker locks out an actor who repeatedly triggers
arg_constraint/credential_exfil denies within a short window, closing
the gap where a probing client could retry indefinitely at whatever
rate the existing rate limiter otherwise allows. Wired through
CapabilityService, on by default, configurable via
QONTO_DENY_ESCALATION_* env vars. Ordinary denies (authz_denied,
tenant_scope) never count toward it.
Also adds specs/security-genome.yaml (kings-guard's genome-record
shape, populated now so no rework is needed once a consumer exists).
Verified: pytest -> 39 passed (8 new); REST and MCP smoke scripts both
pass against fixtures; compileall clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:59:06 +02:00
|
|
|
## 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`.
|