qonto-assistant/workplans/QONTO-WP-0004-security-hardening-and-scale-to-zero.md
tegwick 00191ee841 QONTO-WP-0004: mark T06 done
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:28:09 +02:00

216 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
id: QONTO-WP-0004
type: workplan
title: "Security hardening and scale-to-zero facade for internet exposure"
domain: infotech
repo: qonto-assistant
status: active
owner: claude
topic_slug: the-custodian
created: "2026-07-23"
updated: "2026-07-23"
state_hub_workstream_id: "81a476ac-06d5-4e98-a2ed-1518b16863aa"
---
# Security hardening and scale-to-zero facade for internet exposure
Implements `docs/SecurityPractice.md`, written ahead of deploying
`qonto-assistant` to `railiance01`. This is the first fleet service that
must be reachable by clients outside the cluster (laptop-based agent
harness sessions) while holding a real company bank credential — a risk
class above every other agent-facing service shipped so far.
Some of this workplan's scope depends on systems this repo does not own
(`key-cape`, `flex-auth`, `tenant-engine`, Railiance placement). Those
dependencies are tracked explicitly per task rather than assumed away; the
`kings-guard`-owned portion is tracked as a separate intake against
`KG-WP-0002`, not duplicated here.
## Task: Security Genome record and audit-stream review
```task
id: QONTO-WP-0004-T01
status: done
priority: high
state_hub_task_id: "e93b2027-dd28-464b-8ea3-f7540f91caad"
```
**2026-07-23:** `specs/security-genome.yaml` written per
`kings-guard/specs/NetKingdomImmuneArchitecture.md` §9.1's schema —
capabilities provided/consumed, expected egress (OpenBao + Qonto API only),
data classification, recovery expectations, and explicit tolerances for
today's known-temporary gaps (shared-secret bearer auth, self-asserted actor
claims). Confirmed `AuditLogger`'s existing event shape (actor, capability,
decision, deny_reason, latency, upstream status, policy version) already
matches the Immune Observation contract closely enough that no schema
rework is anticipated once a real consumer exists.
Done when: genome record exists and is reviewed against the blueprint
schema; no code changes required for this task.
## Task: Deny-escalation lockout
```task
id: QONTO-WP-0004-T02
status: done
priority: high
state_hub_task_id: "3b10dd59-b03f-4d2b-8164-aa2a1c8c06c4"
```
**2026-07-23:** `src/qonto_assistant/security_watch.py::DenyEscalationTracker`
locks out an actor who triggers `arg_constraint`/`credential_exfil` policy
denials `QONTO_DENY_ESCALATION_THRESHOLD` times (default 3) within
`QONTO_DENY_ESCALATION_WINDOW_SECONDS` (default 60s), for
`QONTO_DENY_ESCALATION_LOCKOUT_SECONDS` (default 300s). Wired into
`CapabilityService._execute` ahead of the policy kernel call; on by default
via `QONTO_DENY_ESCALATION_ENABLED`. Ordinary denies (`authz_denied`,
`tenant_scope`, `unknown_capability`) never count. This needed no external
dependency — it closes a real gap using only what already exists (the audit
stream and rate limiter).
Verified: `tests/test_deny_escalation.py` (8 new tests: tracker unit tests
+ service integration for both the escalating and non-escalating paths);
full suite `pytest` → 39 passed; REST and MCP smoke scripts both pass
against fixtures; `compileall` clean.
Done when: threshold/window/lockout are configurable, on by default, and
proven not to affect ordinary policy-deny traffic.
## Task: Replace shared-secret bearer token with key-cape identity
```task
id: QONTO-WP-0004-T03
status: done
priority: high
state_hub_task_id: "f02b1cc0-4afc-44e5-b71d-545a13a3e12b"
```
**2026-07-24:** No `key-cape`-side coordination was actually needed — its
`/jwks` is a standard RS256 endpoint. `KeyCapeTokenVerifier`
(`src/qonto_assistant/key_cape_auth.py`) fetches/caches signing keys over
httpx, validates `iss`/`aud`/`exp` and the IAM Profile v0.3 required claims,
and derives `ActorClaims` from the token. Wired into
`auth.actor_claims_from_headers` (the single seam both REST and MCP use); a
verified bearer token now takes precedence over self-asserted `X-Actor-*`
headers, and can be made mandatory via `QONTO_KEY_CAPE_REQUIRED` once real
tokens are issued to callers. Off by default (no
`QONTO_KEY_CAPE_JWKS_URL` set). `QONTO_ASSISTANT_MCP_TOKEN` remains a
documented local-dev/legacy fallback per `docs/mcp-integration.md`.
Verified: 13 new tests using a real generated RSA keypair + JWKS served
over `httpx.MockTransport` (valid/expired/wrong-audience/wrong-issuer/
missing-claim/unknown-key/rotated-key tokens, plus the auth.py precedence
and required-vs-optional paths).
## Task: Register and enforce `finance.qonto.read` in flex-auth
```task
id: QONTO-WP-0004-T04
status: done
priority: high
state_hub_task_id: "fce7bf0e-674a-461f-8f2f-42d7dde3a722"
```
**2026-07-24:** Registered `qonto-assistant` in `flex-auth`
(`flex-auth/examples/qonto-assistant/`, modeled directly on
`examples/tenant-engine/`): one resource type (`finance-snapshot`), one
action (`finance.qonto.read`), a Rego policy gating on
`resource.system` + `action` + `subject.type` + tenant match. Tenant
capability-role/plan liveness (`VEN`/`CUS`) is deliberately *not* encoded in
that policy — kept as a separate `tenant-engine` live-lookup check, per
both repos' policy-scope notes.
`LiveAuthorizationGate` (`src/qonto_assistant/live_authorization.py`)
combines `FlexAuthCheckClient.is_allowed()` (live `POST /v1/check`) and
`TenantEngineClient.active_roles()` (live `GET
/tenants/{id}/roles/live`, gated on `QONTO_TENANT_ENGINE_REQUIRED_ROLES`,
default `VEN,CUS`) and is wired into `CapabilityService._execute` ahead of
the internal policy kernel. Both clients fail closed by construction.
Replaces `QONTO_ASSISTANT_ENFORCE_SCOPE`'s cached-claim check as the live
authorization control point; off by default (no `QONTO_FLEX_AUTH_URL` set).
Verified beyond mocked unit tests: ran a real `flex-auth serve` loaded with
the registered policy (`flex-auth test-policy`/`load-registry`/`check` all
pass) and a real `tenant-engine` instance seeded with a `VEN` grant for
`tenant:friendly:binky`, and exercised this repo's actual clients against
both live processes over real HTTP — `allow` for the correct tenant,
`live_authz_denied` for a mismatched one. 28 new unit tests.
## Task: Facade / scale-to-zero activator — design and reference implementation
```task
id: QONTO-WP-0004-T05
status: todo
priority: high
state_hub_task_id: "e8a87292-488d-4871-be3e-59a5b6a38694"
```
Per `docs/SecurityPractice.md` §2/§6: a thin, always-on facade is the only
internet-facing component. It authenticates (key-cape) and authorizes
(flex-auth pre-check) *before* waking the real service, then scales the
backend `Deployment` 0→1, waits on `/v1/health`, proxies through, and scales
back to 0 after an idle timeout. `qonto-assistant`'s raw REST/MCP port must
never be bound to a publicly-reachable address in any deployment.
Check whether the target `railiance01` cluster already runs Knative Serving
before building a custom activator — if it does, this task becomes
integration (Knative Service manifest + autoscaling annotations) rather
than new code. If not, scope a minimal reference implementation (one
Service, one Deployment, one wake/idle controller) sized to this repo's
narrow surface, not a general-purpose PaaS.
**Depends on:** a decision on where this component lives (this repo,
Railiance, or a new dedicated repo) and Railiance cluster capabilities
(Knative or not) — flag both as open questions rather than assuming.
Done when: an unauthenticated request never triggers a backend wake; an
authenticated+authorized request gets proxied through after a bounded
cold-start; the backend scales back to 0 after the configured idle window.
## Task: Isolation placement request to Railiance
```task
id: QONTO-WP-0004-T06
status: done
priority: medium
state_hub_task_id: "7cd52269-ca0a-4b09-ab51-f02ed71604f2"
```
**2026-07-24:** Made the request concrete and reviewable (this task's own
stated scope — not to implement scheduling itself). Delivered:
- `deploy/k8s/qonto-assistant/` — dedicated namespace (not shared with
`activity-core`), `Deployment` at `replicas: 0` (meant to be scaled 0↔1 by
the facade, T05), `ClusterIP`-only `Service`, default-deny `NetworkPolicy`.
Modeled on `llm-connect`'s real deployment, tightened per
`docs/SecurityPractice.md` §7. Verified: `kubectl kustomize` renders all
six resources cleanly.
- **CCR-2026-0009** (in `railiance-platform`, `status: proposed`) — a
workload-scoped Kubernetes-auth access lane into the existing
`tenants/binky/qonto-api` credential, since CCR-2026-0008 is human/OIDC
admin access only and unusable by a running pod. Mirrors CCR-2026-0003's
llm-connect pattern. Paired draft `ClusterSecretStore` also added. Both
validated against `schemas/credential-change-request.schema.yaml`.
- `railiance/app.toml` — staged-promotion contract, `criticality = "critical"`
(mandatory human approval before Stage 2 exposure and Stage 3 promotion,
per the contract's own rule for production-critical workloads). Validated
against `railiance-cluster/schemas/railiance-app.schema.json`.
**Still pending, explicitly not this task's to resolve:** CCR-2026-0009
needs platform-operator + binky-tenant-owner approval; the actual isolation
tier (I1 vs I2) is Railiance's scheduling call once the manifests are
reviewed; whether the facade (T05) co-locates in this namespace is assumed
in `networkpolicy.yaml` but not yet decided.
## Task: Closure review
```task
id: QONTO-WP-0004-T07
status: todo
priority: low
state_hub_task_id: "2a2da206-d182-4a72-aea4-56e58ae74c76"
```
Close when T03T06 land or are explicitly deferred with a recorded reason.
T01/T02 already shipped without waiting on external dependencies. Run
`statehub fix-consistency`.