Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ec5-7e2b-7743-ac08-719e1b0f42e2
14 KiB
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_TOKENbecomes 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 gapdocs/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 narrowerfinance.qonto.read.transactions/.export) as aflex-authresource. - The facade (or the assistant itself, on cold-start request) calls
flex-authfor a live decision rather than trusting a cached claim for this resource class — mirroring the same reasoningtenant-engineapplies to itself: "a staleVENgrant 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 hardcodeddefault_tenant_id="binky"assumption inconfig.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 ofqonto-assistantitself should remain cluster-internal (ClusterIP), matching the patternrailiance-rhythm's host timers already use forllm-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-capeorflex-authis 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
Deploymentruns atreplicas: 0when 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: 10–15 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 — seeQONTO-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: PT0Sapplies trivially since there is no persisted data).
9. King's Guard observation mapping
King's Guard has observed the structured allow/deny stream. The source keeps ownership of its genome and emission claims; the observer independently checks the records and completeness evidence it receives.
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 event shape includes actor, capability, decision, deny reason,
latency, upstream HTTP status, policy version, identity binding, and egress
destination (see tests/test_audit.py, tests/test_audit_parity.py). Every
record also carries a process-instance id and monotonic stream sequence.
The source-owned declaration in specs/audit-emission-cadence.yaml classifies
audit.deny as load-bearing and low-volume. A startup heartbeat, periodic
heartbeat (24 hours by default), and best-effort shutdown heartbeat publish
cumulative and window transition counts. Consumers compare those counts and
sequence continuity with observed records. /v1/audit/reconciliation exposes
the same non-secret current source counts for an authenticated diagnostic read.
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. The current
DenyEscalationTracker branches directly on the same decision path that emits
the audit event; it does not consume the audit stream. This is a Fast Local Loop
(NetKingdomImmuneArchitecture.md §14.1) response that does not need to wait
for any future component, while the stream remains load-bearing for estate
observation rather than for the local lockout.
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 |
verifier shipped; live enforcement is runtime-configured |
finance.qonto.read resource + live decision call |
qonto-assistant + flex-auth |
client and policy shipped; live use is runtime-configured |
| Live tenant-role gate | qonto-assistant + tenant-engine |
client shipped; live use requires a reachable deployment URL |
| 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 |
| Audit-stream observation and completeness acceptance | kings-guard |
consuming events; cadence/reconciliation acceptance pending |
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).