qonto-assistant/docs/operator-runbook.md
tegwick 1f0f979e36 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

243 lines
7.2 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
- 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`.