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:
parent
78eb2a819c
commit
3faf1fed71
9 changed files with 655 additions and 1 deletions
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue