qonto-assistant/docs/SecurityPractice.md
tegwick 3faf1fed71 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

13 KiB
Raw Permalink Blame History

Security Practice — Internet Exposure, Governance, and Scale-to-Zero

Status: draft, 2026-07-23. Written before deployment to railiance01. Context: this is the first fleet service that (a) must be reachable by clients outside the cluster (laptop-based Claude Code/Cursor/Codex sessions), and (b) is the sole holder of a real company bank credential. That combination raises the risk class above every other agent-facing service shipped so far (llm-connect, mail-triage), which are cluster-internal only.

Related: specs/ArchitectureBlueprint.md (service architecture and v1 policy), docs/mcp-integration.md (current auth model and its called-out gaps), docs/operator-runbook.md (day-2 operation).

This document assumes the kings-guard NetKingdom Immune Architecture exists as designed in kings-guard/specs/NetKingdomImmuneArchitecture.md (currently draft, no implementation) and describes how qonto-assistant would sit inside it, alongside the real, already-running NetKingdom components (key-cape, flex-auth, tenant-engine, OpenBao/ops-warden). Concrete requirements handed to kings-guard to prioritize its own build are tracked as an intake — see KG-WP-0002 and the corresponding intake record.


1. Why this service is a different risk class

Property Prior services (llm-connect, mail-triage) qonto-assistant
Client population in-cluster jobs/timers only in-cluster and external harness sessions (laptops)
Network exposure cluster-internal DNS only needs a real internet-reachable address
Credential held none / low-value live company bank API key (tenants/binky/qonto-api)
Data classification operational metadata tenant-confidential financial data
Blast radius if compromised mail queue drift real financial data disclosure; reputational and possibly regulatory impact

Every control below exists to answer one question: what stops an internet-reachable process, holding a real bank credential, from becoming the fleet's first genuinely damaging incident?


2. Layered design

Internet
   │
   ▼
┌─────────────────────────────────────────────────────────────┐
│  Facade / Activator  (always-on, holds NO bank credential)  │
│  - key-cape token verification                              │
│  - flex-auth pre-check (finance.qonto.read)                  │
│  - wakes the real service only after both pass                │
│  - idle-timeout scale-back-to-zero                            │
└───────────────────────────┬───────────────────────────────────┘
                            │ (only on authenticated+authorized wake)
                            ▼
┌─────────────────────────────────────────────────────────────┐
│  qonto-assistant  (scaled 0↔1, isolation profile I1/I2)      │
│  - policy kernel (default-deny; existing, unchanged)         │
│  - REST + MCP surfaces (existing, unchanged)                  │
│  - OpenBao fetch on cold start only, short TTL                │
│  - audit emission shaped as Kings Guard Immune Observations   │
└───────────────────────────┬───────────────────────────────────┘
                            │
                            ▼
                    OpenBao (tenants/binky/qonto-api)
                            │
                            ▼
                      Qonto thirdparty API

The facade and the scale-to-zero controller are the same component. Building it once satisfies both the cost concern (service is idle most of the time and should not burn compute) and the security concern (nothing reachable from the internet is allowed to trigger a cold start of the credential-holding process without first clearing identity and authorization checks). This is deliberate: a "wake on any request" facade without the auth gate would just be a new, free DoS/cost-exhaustion lever.


3. Identity — replace the interim bearer token with key-cape

docs/mcp-integration.md already documents today's interim state: a shared-secret bearer token (QONTO_ASSISTANT_MCP_TOKEN) stands in for real workload/client identity, explicitly because "no OIDC issuer exists in this fleet yet."

That premise no longer holds for this deployment. key-cape (Authelia + LLDAP + privacyIDEA) is the fleet's real, already-running IAM Profile issuer — it is SSO today for ops/Temporal UI (NK-WP-0021). Before qonto-assistant is internet-facing:

  • The facade verifies a key-cape-issued IAM Profile token (human operator session or service/workload identity) on every inbound request, before any wake decision.
  • The shared-secret QONTO_ASSISTANT_MCP_TOKEN becomes fixture/local-dev only (as already documented) and must never be the auth boundary for a real deployment.
  • Actor claims (X-Actor-ID, X-Tenant-ID, X-Actor-Lane, X-Actor-Scopes) stop being self-asserted headers and are instead derived from the verified token — closing the gap docs/mcp-integration.md §Auth model calls out explicitly ("self-asserted today, not cryptographically bound").

4. Authorization — register finance.qonto.read in flex-auth now

config.py already reserves QONTO_ASSISTANT_REQUIRED_SCOPE / QONTO_ASSISTANT_ENFORCE_SCOPE for exactly this and ships with enforcement off. Given internet exposure, this should not wait for "Phase 3" — it is the actual authorization control point standing between an authenticated caller and a live bank-data read.

  • Register finance.qonto.read (and, if useful later, a narrower finance.qonto.read.transactions / .export) as a flex-auth resource.
  • The facade (or the assistant itself, on cold-start request) calls flex-auth for a live decision rather than trusting a cached claim for this resource class — mirroring the same reasoning tenant-engine applies to itself: "a stale VEN grant surviving a plan cancellation is not an acceptable risk for money-movement or credential-vending actions" — read-only finance visibility deserves the same live-check discipline, not just write paths.
  • Gate on live tenant capability role via tenant-engine (VEN/CUS, non-exclusive) and plan, replacing the current hardcoded default_tenant_id="binky" assumption in config.py. A tenant whose plan lapses should lose read access the same request cycle, not whenever a cache expires.

5. Network exposure — the facade is the only internet-facing thing

qonto-assistant's raw REST/MCP port must never be bound to a publicly-reachable address, in any deployment. Concretely:

  • The Kubernetes Service/ingress in front of qonto-assistant itself should remain cluster-internal (ClusterIP), matching the pattern railiance-rhythm's host timers already use for llm-connect.
  • The facade is the sole component with an external address. It is deliberately dumb: no policy kernel, no bank credential, no Qonto client — its only job is auth-gate, wake, proxy, and idle-timeout.
  • Failure mode: if key-cape or flex-auth is unreachable, the facade fails closed (refuses to wake the backend), per Kings Guard principle 6.11 ("fail securely, not blindly") — an outage in the auth path must not silently become an open-access mode.

6. Scale-to-zero — cost control and security control together

  • Facade holds the public address; backend Deployment runs at replicas: 0 when idle.
  • On an authenticated+authorized request, facade scales the backend to 1, waits on GET /v1/health, then proxies through.
  • An idle-timeout watcher (same component or a sidecar) scales back to 0 after a configurable window (proposed default: 1015 minutes of no traffic) — tunable per deployment, not hardcoded.
  • If the target cluster already runs Knative Serving, this is close to the built-in Activator + scale-to-zero autoscaler pattern and should reuse it rather than reinventing one. Otherwise a minimal custom controller is small and narrow in scope (one Deployment, one Service, one wake/idle state machine) and should be scoped as its own follow-on task rather than folded into qonto-assistant's own codebase — see QONTO-WP-0004.
  • Security side effect, not just a cost one: because OpenBao secret fetch already uses a short TTL (qonto_secret_ttl_seconds), scale-to-zero shrinks the bank credential's residency window in process memory to "only while an authenticated request is actually being served" — smaller than today's always-on posture, for free.

7. Isolation profile

Per kings-guard's isolation-profile classification (NetKingdomImmuneArchitecture.md §11), given:

  • tenant-confidential financial data classification,
  • internet reachability (via the facade),
  • sole custody of a real bank credential,

qonto-assistant should run at I1 Reinforced at minimum (dedicated node pool or sandboxed runtime) rather than the shared/logical-isolation default (I0) other low-risk internal services use. I2 Dedicated (tenant-specific control/compute plane) is worth considering given it is the single component in the fleet holding this particular credential — the decision belongs to whoever owns Railiance placement for this workload, not to this repo alone.

8. Recovery

qonto-assistant already has the properties Kings Guard's "recovery is a first-class capability" principle (AD-006) asks for, largely as a side effect of how it was built, not extra work:

  • Stateless — no persisted secrets or session state; redeployable at any time.
  • Short-TTL credential — OpenBao lease expires quickly; a fresh pod fetches its own, it never inherits a stale one.
  • Cheap reconstitution — "kill the pod, let the facade cold-start a fresh one on the next legitimate request" is a complete, low-cost recovery procedure with no data-loss risk (maximum_data_loss: PT0S applies trivially since there is no persisted data).

9. Kings Guard mapping (prep now, cheap; enforcement later)

Nothing below requires kings-guard to exist yet. It is preparation so qonto-assistant needs zero rework once a sentinel-mesh or decision plane does exist.

9.1 Security Genome record

See specs/security-genome.yaml for the concrete record following NetKingdomImmuneArchitecture.md §9.1's schema — declared purpose, capabilities provided/consumed, expected egress (Qonto API + OpenBao only, nothing else), data classification, and recovery expectations.

9.2 Audit stream is already observation-shaped

AuditLogger's existing event shape (actor, capability, decision, deny_reason, latency, upstream HTTP status, policy version — see tests/test_audit.py, tests/test_audit_parity.py) already matches Kings Guard's Immune Observation contract closely enough that no schema rework should be needed later — just a new consumer pointed at the same stream.

9.3 A concrete, actionable-today signal

This does not require any Kings Guard component: repeated arg_constraint or credential_exfil deny reasons from the same actor within a short window is a real, current signal. It should trip a tightened rate limit or a temporary lockout for that actor now, using the audit stream and rate limiter that already exist — this is a Fast Local Loop (NetKingdomImmuneArchitecture.md §14.1) response that does not need to wait for any future component.


10. What this repo can do unilaterally vs. what it depends on

Item Owner Status
Security Genome record qonto-assistant can ship now
Audit-stream shape review against Immune Observation contract qonto-assistant can ship now
Actor lockout on repeated deny signals qonto-assistant can ship now
key-cape token verification in place of bearer token qonto-assistant + key-cape needs key-cape client integration support
finance.qonto.read resource + live decision call qonto-assistant + flex-auth needs the resource registered in flex-auth
Live tenant-role gate qonto-assistant + tenant-engine needs a tenant-engine lookup/cache API call wired in
Facade / scale-to-zero activator new component (home TBD — Railiance or a dedicated repo) design only so far
I1/I2 isolation placement on railiance01 Railiance needs a placement decision
Sentinel-mesh / decision-plane consumption of the audit stream kings-guard does not exist yet — see intake

Tracked as QONTO-WP-0004 in this repo, with the kings-guard-owned portion tracked as an intake against KG-WP-0002 (pilot-lane selection).