--- 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" --- # 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 ``` **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 ``` **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: todo priority: high ``` Verify `key-cape`-issued IAM Profile tokens at the request boundary (facade or assistant, depending on where T05 lands the auth check) in place of `QONTO_ASSISTANT_MCP_TOKEN`. Derive `X-Actor-*`-equivalent claims from the verified token instead of trusting self-asserted headers — closing the gap `docs/mcp-integration.md`'s auth-model section calls out explicitly. **Depends on:** `key-cape` exposing a client-verifiable token/JWKS surface this service can validate against — needs coordination with `key-cape`, not something this repo can complete alone. Done when: a request bearing a valid key-cape token is accepted with claims derived from the token; the shared-secret bearer path is demoted to fixture/local-dev-only, matching what `docs/mcp-integration.md` already documents as the intended boundary. ## Task: Register and enforce `finance.qonto.read` in flex-auth ```task id: QONTO-WP-0004-T04 status: todo priority: high ``` Register `finance.qonto.read` as a `flex-auth` resource. Call `flex-auth` for a live decision on this resource rather than relying on `QONTO_ASSISTANT_ENFORCE_SCOPE`'s current cached-claim check — mirroring `tenant-engine`'s own reasoning that a stale grant is not an acceptable risk for money-adjacent actions, applied here to read access. Replace the hardcoded `default_tenant_id="binky"` assumption in `config.py` with a live `tenant-engine` capability-role + plan lookup (`VEN`/`CUS`) so a lapsed tenant loses read access on the next request, not whenever a cache expires. **Depends on:** `flex-auth` resource registration and a `tenant-engine` lookup/cache API this repo can call — both external, not something this repo can complete alone. Done when: an unauthorized tenant/role is denied by a live `flex-auth` decision, not a locally cached scope check; policy tests cover both the allow and the newly-live-checked deny path. ## Task: Facade / scale-to-zero activator — design and reference implementation ```task id: QONTO-WP-0004-T05 status: todo priority: high ``` 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: todo priority: medium ``` Per `docs/SecurityPractice.md` §7: request I1 "Reinforced" (dedicated node pool / sandboxed runtime) at minimum for the `qonto-assistant` deployment on `railiance01`, given tenant-confidential financial-data classification, internet reachability via the facade, and sole custody of the bank credential. I2 "Dedicated" (own namespace/keys) is worth considering; the placement decision belongs to whoever owns Railiance scheduling for this workload, not this repo. **Depends on:** a Railiance-side placement decision — this task's job is to make the request concrete and reviewable, not to implement scheduling. Done when: a placement decision is recorded (even if the decision is "I0 for now, revisit before going live") and referenced back into this workplan. ## Task: Closure review ```task id: QONTO-WP-0004-T07 status: todo priority: low ``` Close when T03–T06 land or are explicitly deferred with a recorded reason. T01/T02 already shipped without waiting on external dependencies. Run `statehub fix-consistency`.