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>
This commit is contained in:
tegwick 2026-07-23 22:59:06 +02:00
parent 78eb2a819c
commit 3faf1fed71
9 changed files with 655 additions and 1 deletions

View file

@ -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
),
)

View file

@ -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"),

View 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()

View file

@ -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):