Compare commits
2 commits
78eb2a819c
...
f1a696766b
| Author | SHA1 | Date | |
|---|---|---|---|
| f1a696766b | |||
| 3faf1fed71 |
10 changed files with 841 additions and 1 deletions
246
docs/SecurityPractice.md
Normal file
246
docs/SecurityPractice.md
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
# 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`](../../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
|
||||
|
||||
```text
|
||||
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: 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 — 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).
|
||||
|
|
@ -206,3 +206,16 @@ That consumer-side write remains outside this repo.
|
|||
- 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.
|
||||
|
||||
## 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`.
|
||||
|
|
|
|||
86
specs/security-genome.yaml
Normal file
86
specs/security-genome.yaml
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Security Genome record for qonto-assistant.
|
||||
#
|
||||
# Schema per kings-guard/specs/NetKingdomImmuneArchitecture.md §9.1
|
||||
# ("Minimum Genome Record"). Kings Guard does not exist as a running system
|
||||
# yet — this record is written now so no rework is needed once an admission
|
||||
# or posture-assessment consumer does exist. See docs/SecurityPractice.md.
|
||||
|
||||
security_genome_record:
|
||||
id: kg:genome:qonto-assistant
|
||||
version: 0.1.0
|
||||
|
||||
ownership:
|
||||
tenant_id: binky
|
||||
accountable_owner: bernd.worsch
|
||||
operational_owner: qonto-assistant
|
||||
|
||||
purpose:
|
||||
intent: >
|
||||
Sole holder of the company Qonto bank API credential; exposes
|
||||
read-only finance capabilities (org summary, transactions,
|
||||
CostRunRate hints) to authorized agent harnesses and operators via a
|
||||
governed REST + MCP surface, so no client ever holds the bank key
|
||||
directly.
|
||||
criticality: high
|
||||
|
||||
identities:
|
||||
workload_identity: "TBD — pending key-cape/workload-identity integration (QONTO-WP-0004)"
|
||||
deployment_identity: "TBD — pending Railiance placement decision"
|
||||
|
||||
capabilities:
|
||||
provides:
|
||||
- finance.qonto.read
|
||||
consumes:
|
||||
- openbao-secret.read # tenants/binky/qonto-api
|
||||
- qonto-thirdparty-api.read
|
||||
|
||||
communication:
|
||||
ingress:
|
||||
- caller: facade-activator # not yet built — see docs/SecurityPractice.md §6
|
||||
protocol: https
|
||||
egress:
|
||||
- destination: openbao
|
||||
protocol: https
|
||||
- destination: qonto-thirdparty-api
|
||||
protocol: https
|
||||
# No other egress is expected or permitted. Any additional outbound
|
||||
# destination observed at runtime is, by definition, a deviation from
|
||||
# this genome.
|
||||
|
||||
data:
|
||||
classifications:
|
||||
- tenant-confidential
|
||||
- financial
|
||||
persistence: ephemeral
|
||||
export_allowed: false
|
||||
|
||||
provenance:
|
||||
signed_artifact_required: false # not yet implemented fleet-wide
|
||||
approved_registry: "TBD"
|
||||
sbom_required: false # not yet implemented; tracked as a gap
|
||||
reproducible_build_preferred: true
|
||||
|
||||
expected_behavior:
|
||||
max_request_rate: "per QONTO_RATE_LIMIT_REQUESTS / QONTO_RATE_LIMIT_WINDOW_SECONDS (config.py)"
|
||||
external_network_access: false # beyond the two declared egress destinations
|
||||
interactive_shell: prohibited
|
||||
privilege_escalation: prohibited
|
||||
|
||||
recovery:
|
||||
redeployable: true
|
||||
maximum_recovery_time: PT5M # stateless; cold-start dominated by scale-to-zero wake latency
|
||||
maximum_data_loss: PT0S # no persisted state
|
||||
|
||||
tolerances:
|
||||
- id: local-dev-shared-secret-auth
|
||||
description: >
|
||||
QONTO_ASSISTANT_MCP_TOKEN shared-secret bearer auth is tolerated
|
||||
only for fixture-backed local development, never for a deployment
|
||||
holding real Qonto credentials.
|
||||
expires_at: null # revisit once key-cape integration (QONTO-WP-0004) lands
|
||||
- id: self-asserted-actor-claims
|
||||
description: >
|
||||
X-Actor-* headers are self-asserted, not yet cryptographically
|
||||
bound to verified identity. Tolerated until key-cape/flex-auth
|
||||
integration (QONTO-WP-0004) closes this.
|
||||
expires_at: null
|
||||
|
|
@ -21,6 +21,7 @@ from qonto_assistant.mcp_server import create_mcp_server
|
|||
from qonto_assistant.policy import PolicyEngine
|
||||
from qonto_assistant.qonto_client import FixtureQontoClient, QontoClient
|
||||
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||
from qonto_assistant.security_watch import DenyEscalationTracker
|
||||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
|
||||
|
|
@ -171,6 +172,15 @@ def _build_service(
|
|||
),
|
||||
concurrency_limiter=concurrency_limiter
|
||||
or ConcurrencyLimiter(limit=settings.max_concurrency),
|
||||
deny_escalation_tracker=(
|
||||
DenyEscalationTracker(
|
||||
threshold=settings.deny_escalation_threshold,
|
||||
window_seconds=settings.deny_escalation_window_seconds,
|
||||
lockout_seconds=settings.deny_escalation_lockout_seconds,
|
||||
)
|
||||
if settings.deny_escalation_enabled
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ class Settings:
|
|||
rate_limit_requests: int
|
||||
rate_limit_window_seconds: int
|
||||
max_concurrency: int
|
||||
deny_escalation_enabled: bool
|
||||
deny_escalation_threshold: int
|
||||
deny_escalation_window_seconds: int
|
||||
deny_escalation_lockout_seconds: int
|
||||
credential_source: str
|
||||
openbao_path: str
|
||||
openbao_command: str
|
||||
|
|
@ -66,6 +70,10 @@ class Settings:
|
|||
rate_limit_requests=int(os.getenv("QONTO_RATE_LIMIT_REQUESTS", "20")),
|
||||
rate_limit_window_seconds=int(os.getenv("QONTO_RATE_LIMIT_WINDOW_SECONDS", "60")),
|
||||
max_concurrency=int(os.getenv("QONTO_MAX_CONCURRENCY", "4")),
|
||||
deny_escalation_enabled=os.getenv("QONTO_DENY_ESCALATION_ENABLED", "true").lower() == "true",
|
||||
deny_escalation_threshold=int(os.getenv("QONTO_DENY_ESCALATION_THRESHOLD", "3")),
|
||||
deny_escalation_window_seconds=int(os.getenv("QONTO_DENY_ESCALATION_WINDOW_SECONDS", "60")),
|
||||
deny_escalation_lockout_seconds=int(os.getenv("QONTO_DENY_ESCALATION_LOCKOUT_SECONDS", "300")),
|
||||
credential_source=os.getenv("QONTO_CREDENTIAL_SOURCE", "env"),
|
||||
openbao_path=os.getenv("QONTO_OPENBAO_PATH", "tenants/binky/qonto-api"),
|
||||
openbao_command=os.getenv("QONTO_OPENBAO_COMMAND", "bao"),
|
||||
|
|
|
|||
85
src/qonto_assistant/security_watch.py
Normal file
85
src/qonto_assistant/security_watch.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Callable
|
||||
from threading import Lock
|
||||
|
||||
from qonto_assistant.errors import QontoAssistantError
|
||||
|
||||
# Deny reasons that indicate probing or exfiltration attempts rather than
|
||||
# ordinary client mistakes (e.g. a too-large page_size is `arg_constraint`
|
||||
# but so is a scripted attempt to walk every constraint boundary; treating
|
||||
# it as escalation-worthy is deliberately conservative). See
|
||||
# docs/SecurityPractice.md §9.3 — this is a Fast Local Loop response
|
||||
# (NetKingdomImmuneArchitecture.md §14.1) that needs no external component.
|
||||
ESCALATING_DENY_REASONS = frozenset({"arg_constraint", "credential_exfil"})
|
||||
|
||||
|
||||
class ActorLockedOutError(QontoAssistantError):
|
||||
"""Raised when an actor is temporarily locked out after repeated
|
||||
escalation-worthy policy denials."""
|
||||
|
||||
error_code = "actor_locked_out"
|
||||
status_code = 429
|
||||
|
||||
def __init__(self, actor_key: str, *, retry_after_seconds: float) -> None:
|
||||
super().__init__(f"actor {actor_key} locked out for {retry_after_seconds:.0f}s")
|
||||
self.actor_key = actor_key
|
||||
self.retry_after_seconds = retry_after_seconds
|
||||
|
||||
|
||||
class DenyEscalationTracker:
|
||||
"""Tracks escalation-worthy policy denials per actor and imposes a
|
||||
temporary lockout once a threshold is crossed within a window.
|
||||
|
||||
This is intentionally simple in-process state, not a replacement for a
|
||||
real decision/response plane (kings-guard, once it exists). It closes
|
||||
the gap today: without it, an actor can retry a credential-exfil or
|
||||
arg-constraint probe indefinitely at whatever rate the rate limiter
|
||||
otherwise allows.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
threshold: int,
|
||||
window_seconds: int,
|
||||
lockout_seconds: int,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self.threshold = threshold
|
||||
self.window_seconds = window_seconds
|
||||
self.lockout_seconds = lockout_seconds
|
||||
self.clock = clock
|
||||
self._deny_events: dict[str, deque[float]] = defaultdict(deque)
|
||||
self._locked_until: dict[str, float] = {}
|
||||
self._lock = Lock()
|
||||
|
||||
def check(self, actor_key: str) -> None:
|
||||
"""Raise ActorLockedOutError if the actor is currently locked out."""
|
||||
now = self.clock()
|
||||
with self._lock:
|
||||
locked_until = self._locked_until.get(actor_key)
|
||||
if locked_until is not None:
|
||||
if now < locked_until:
|
||||
raise ActorLockedOutError(actor_key, retry_after_seconds=locked_until - now)
|
||||
del self._locked_until[actor_key]
|
||||
self._deny_events.pop(actor_key, None)
|
||||
|
||||
def record_deny(self, actor_key: str, reason: str) -> None:
|
||||
"""Record a policy deny outcome; escalate to a lockout if the actor
|
||||
has crossed the threshold of escalation-worthy denials within the
|
||||
window."""
|
||||
if reason not in ESCALATING_DENY_REASONS:
|
||||
return
|
||||
now = self.clock()
|
||||
with self._lock:
|
||||
window_start = now - self.window_seconds
|
||||
bucket = self._deny_events[actor_key]
|
||||
bucket.append(now)
|
||||
while bucket and bucket[0] < window_start:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= self.threshold:
|
||||
self._locked_until[actor_key] = now + self.lockout_seconds
|
||||
bucket.clear()
|
||||
|
|
@ -12,6 +12,7 @@ from qonto_assistant.errors import InvalidRequestError, PolicyDeniedError, Upstr
|
|||
from qonto_assistant.policy import PolicyEngine
|
||||
from qonto_assistant.qonto_client import QontoClientProtocol
|
||||
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||
from qonto_assistant.security_watch import ActorLockedOutError, DenyEscalationTracker
|
||||
|
||||
|
||||
class CapabilityService:
|
||||
|
|
@ -23,12 +24,14 @@ class CapabilityService:
|
|||
audit_logger: AuditLogger,
|
||||
rate_limiter: RateLimiter,
|
||||
concurrency_limiter: ConcurrencyLimiter,
|
||||
deny_escalation_tracker: DenyEscalationTracker | None = None,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.policy = policy
|
||||
self.audit_logger = audit_logger
|
||||
self.rate_limiter = rate_limiter
|
||||
self.concurrency_limiter = concurrency_limiter
|
||||
self.deny_escalation_tracker = deny_escalation_tracker
|
||||
|
||||
def get_accounts(
|
||||
self, *, claims: ActorClaims, request_id: str, protocol: ProtocolName = "rest"
|
||||
|
|
@ -140,8 +143,29 @@ class CapabilityService:
|
|||
protocol=protocol,
|
||||
)
|
||||
started = time.perf_counter()
|
||||
actor_key = f"{claims.tenant_id}:{claims.actor_id}"
|
||||
|
||||
if self.deny_escalation_tracker is not None:
|
||||
try:
|
||||
self.deny_escalation_tracker.check(actor_key)
|
||||
except ActorLockedOutError:
|
||||
self._emit_audit(
|
||||
request_id=request_id,
|
||||
claims=claims,
|
||||
capability_id=capability_id,
|
||||
decision="deny",
|
||||
deny_reason="actor_locked_out",
|
||||
latency_ms=_latency_ms(started),
|
||||
result_count=None,
|
||||
qonto_http_status=None,
|
||||
protocol=protocol,
|
||||
)
|
||||
raise
|
||||
|
||||
decision = self.policy.decide(request)
|
||||
if not decision.allowed:
|
||||
if self.deny_escalation_tracker is not None:
|
||||
self.deny_escalation_tracker.record_deny(actor_key, decision.reason)
|
||||
self._emit_audit(
|
||||
request_id=request_id,
|
||||
claims=claims,
|
||||
|
|
@ -155,7 +179,6 @@ class CapabilityService:
|
|||
)
|
||||
raise PolicyDeniedError(decision)
|
||||
|
||||
actor_key = f"{claims.tenant_id}:{claims.actor_id}"
|
||||
self.rate_limiter.check(actor_key)
|
||||
|
||||
with self.concurrency_limiter.slot(actor_key):
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ def _settings() -> Settings:
|
|||
rate_limit_requests=20,
|
||||
rate_limit_window_seconds=60,
|
||||
max_concurrency=4,
|
||||
deny_escalation_enabled=True,
|
||||
deny_escalation_threshold=3,
|
||||
deny_escalation_window_seconds=60,
|
||||
deny_escalation_lockout_seconds=300,
|
||||
credential_source="env",
|
||||
openbao_path="tenants/binky/qonto-api",
|
||||
openbao_command="bao",
|
||||
|
|
|
|||
179
tests/test_deny_escalation.py
Normal file
179
tests/test_deny_escalation.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from qonto_assistant.audit import AuditLogger
|
||||
from qonto_assistant.contracts import ActorClaims
|
||||
from qonto_assistant.errors import PolicyDeniedError
|
||||
from qonto_assistant.policy import PolicyEngine
|
||||
from qonto_assistant.qonto_client import FixtureQontoClient
|
||||
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||
from qonto_assistant.security_watch import (
|
||||
ActorLockedOutError,
|
||||
DenyEscalationTracker,
|
||||
)
|
||||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
|
||||
|
||||
class _FakeClock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
|
||||
|
||||
def _claims(actor_id: str = "prober") -> ActorClaims:
|
||||
return ActorClaims(actor_id=actor_id, tenant_id="binky", lane="green")
|
||||
|
||||
|
||||
def _service(tracker: DenyEscalationTracker, events: list[dict[str, object]]) -> CapabilityService:
|
||||
return CapabilityService(
|
||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||
policy=PolicyEngine.from_file(
|
||||
POLICY_FILE,
|
||||
required_scope="finance.qonto.read",
|
||||
enforce_scope=False,
|
||||
),
|
||||
audit_logger=AuditLogger(sink=events.append),
|
||||
rate_limiter=RateLimiter(limit=100, window_seconds=60),
|
||||
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||
deny_escalation_tracker=tracker,
|
||||
)
|
||||
|
||||
|
||||
# --- Unit tests for the tracker itself -------------------------------------------------
|
||||
|
||||
|
||||
def test_tracker_allows_denies_below_threshold() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
||||
tracker.check("binky:prober") # should not raise
|
||||
|
||||
|
||||
def test_tracker_locks_out_after_threshold_within_window() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
|
||||
for _ in range(3):
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
||||
with pytest.raises(ActorLockedOutError):
|
||||
tracker.check("binky:prober")
|
||||
|
||||
|
||||
def test_tracker_ignores_non_escalating_deny_reasons() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
|
||||
for _ in range(10):
|
||||
tracker.record_deny("binky:prober", "tenant_scope")
|
||||
tracker.record_deny("binky:prober", "authz_denied")
|
||||
|
||||
tracker.check("binky:prober") # should not raise -- neither reason escalates
|
||||
|
||||
|
||||
def test_tracker_denies_outside_window_do_not_accumulate() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
clock.advance(61)
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
clock.advance(61)
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
||||
tracker.check("binky:prober") # should not raise -- each deny fell outside the prior window
|
||||
|
||||
|
||||
def test_tracker_lockout_expires_after_lockout_window() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
|
||||
for _ in range(3):
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
||||
with pytest.raises(ActorLockedOutError):
|
||||
tracker.check("binky:prober")
|
||||
|
||||
clock.advance(301)
|
||||
tracker.check("binky:prober") # should not raise -- lockout window elapsed
|
||||
|
||||
# Escalation state was cleared, not just the lockout: it takes a full
|
||||
# fresh threshold of denies to lock out again.
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
tracker.check("binky:prober") # should not raise -- only 2 denies since the reset
|
||||
|
||||
|
||||
def test_tracker_is_scoped_per_actor() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
|
||||
for _ in range(3):
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
||||
tracker.check("binky:other-actor") # should not raise -- different actor, untouched
|
||||
|
||||
|
||||
# --- Integration through CapabilityService ---------------------------------------------
|
||||
|
||||
|
||||
def test_service_locks_out_actor_after_repeated_arg_constraint_denies() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=2, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
events: list[dict[str, object]] = []
|
||||
service = _service(tracker, events)
|
||||
|
||||
def _oversized_page_call() -> None:
|
||||
service.list_transactions(
|
||||
claims=_claims(),
|
||||
request_id="req-probe",
|
||||
account_slug=None,
|
||||
page=1,
|
||||
page_size=10_000, # far beyond max_per_page -> arg_constraint
|
||||
window_days=31,
|
||||
status="completed",
|
||||
side=None,
|
||||
protocol="rest",
|
||||
)
|
||||
|
||||
# First two denies cross the threshold=2 escalation bar.
|
||||
for _ in range(2):
|
||||
with pytest.raises(PolicyDeniedError):
|
||||
_oversized_page_call()
|
||||
|
||||
# A third call -- even to an unrelated, otherwise-allowed capability --
|
||||
# is now rejected before the policy kernel is consulted at all.
|
||||
with pytest.raises(ActorLockedOutError):
|
||||
service.get_accounts(claims=_claims(), request_id="req-locked", protocol="rest")
|
||||
|
||||
lockout_events = [event for event in events if event.get("deny_reason") == "actor_locked_out"]
|
||||
assert len(lockout_events) == 1
|
||||
|
||||
|
||||
def test_service_does_not_lock_out_for_ordinary_denies() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=2, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
events: list[dict[str, object]] = []
|
||||
service = _service(tracker, events)
|
||||
|
||||
red_lane_claims = ActorClaims(actor_id="prober", tenant_id="binky", lane="red")
|
||||
for _ in range(5):
|
||||
with pytest.raises(PolicyDeniedError):
|
||||
service.get_accounts(claims=red_lane_claims, request_id="req-lane", protocol="rest")
|
||||
|
||||
# authz_denied (wrong lane) never escalates -- the actor should still be
|
||||
# able to make a legitimate, correctly-scoped call afterwards.
|
||||
payload = service.get_accounts(claims=_claims(), request_id="req-ok", protocol="rest")
|
||||
assert "organization" in payload
|
||||
186
workplans/QONTO-WP-0004-security-hardening-and-scale-to-zero.md
Normal file
186
workplans/QONTO-WP-0004-security-hardening-and-scale-to-zero.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
---
|
||||
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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue