2026-07-22 21:21:05 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _repo_root() -> Path:
|
|
|
|
|
return Path(__file__).resolve().parents[2]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
class Settings:
|
|
|
|
|
service_name: str
|
|
|
|
|
default_tenant_id: str
|
|
|
|
|
default_actor_lane: str
|
|
|
|
|
required_scope: str
|
|
|
|
|
enforce_scope: bool
|
|
|
|
|
policy_file: Path
|
|
|
|
|
qonto_base_url: str
|
|
|
|
|
qonto_fixture_dir: Path | None
|
|
|
|
|
qonto_auth_mode: str
|
|
|
|
|
qonto_organization_path: str
|
|
|
|
|
qonto_transactions_path: str
|
|
|
|
|
qonto_timeout_seconds: float
|
|
|
|
|
qonto_max_retries: int
|
|
|
|
|
qonto_secret_ttl_seconds: int
|
|
|
|
|
rate_limit_requests: int
|
|
|
|
|
rate_limit_window_seconds: int
|
|
|
|
|
max_concurrency: int
|
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
|
|
|
deny_escalation_enabled: bool
|
|
|
|
|
deny_escalation_threshold: int
|
|
|
|
|
deny_escalation_window_seconds: int
|
|
|
|
|
deny_escalation_lockout_seconds: int
|
2026-07-24 00:10:41 +02:00
|
|
|
key_cape_jwks_url: str | None
|
|
|
|
|
key_cape_issuer: str
|
|
|
|
|
key_cape_audience: str
|
|
|
|
|
key_cape_required: bool
|
|
|
|
|
key_cape_cache_seconds: float
|
|
|
|
|
key_cape_timeout_seconds: float
|
QONTO-WP-0004-T04: live flex-auth + tenant-engine authorization gate
Replaces the config-only QONTO_ASSISTANT_ENFORCE_SCOPE cached-claim
check with two live-checked facts, per docs/SecurityPractice.md #4:
1. flex-auth POST /v1/check on finance.qonto.read for the calling
actor/tenant (FlexAuthCheckClient, modeled on tenant-engine's own
client for the same API). Registration lives in the flex-auth repo
(examples/qonto-assistant/) -- rules + embedded tests verified with
flex-auth test-policy/load-registry/check, and a live flex-auth
serve hit by this exact client over real HTTP (not a mock).
2. tenant-engine's live capability-role lookup
(GET /tenants/{id}/roles/live), denying unless the tenant currently
holds one of QONTO_TENANT_ENGINE_REQUIRED_ROLES (default VEN,CUS) --
optional and additive to the flex-auth check.
Both clients fail closed by construction (unreachable/malformed/non-2xx
all deny, never grant), matching FlexAuthCheckClient's existing
fail-closed philosophy elsewhere in the fleet. LiveAuthorizationGate
combines both and is wired into CapabilityService._execute ahead of
the internal policy kernel; off by default (no QONTO_FLEX_AUTH_URL
set) so existing deployments are unaffected until configured.
Verified beyond mocked unit tests: ran a real `flex-auth serve` loaded
with the registered policy, and a real tenant-engine instance seeded
with a VEN grant for tenant:friendly:binky, and exercised this repo's
actual FlexAuthCheckClient/TenantEngineClient/LiveAuthorizationGate
against both live processes over real HTTP -- allow for the correct
tenant, live_authz_denied for a mismatched tenant.
28 new unit tests (flex_auth_client, tenant_engine_client,
live_authorization_gate + CapabilityService integration). Full suite
-> 80 passed; REST/MCP smokes and compileall still clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:19:04 +02:00
|
|
|
flex_auth_base_url: str | None
|
|
|
|
|
flex_auth_timeout_seconds: float
|
|
|
|
|
tenant_engine_base_url: str | None
|
|
|
|
|
tenant_engine_timeout_seconds: float
|
|
|
|
|
tenant_engine_required_roles: frozenset[str]
|
2026-07-22 21:21:05 +02:00
|
|
|
credential_source: str
|
|
|
|
|
openbao_path: str
|
|
|
|
|
openbao_command: str
|
|
|
|
|
openbao_timeout_seconds: float
|
2026-07-23 09:41:25 +02:00
|
|
|
mcp_auth_token: str | None
|
2026-07-22 21:21:05 +02:00
|
|
|
host: str
|
|
|
|
|
port: int
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_env(cls) -> "Settings":
|
|
|
|
|
policy_file = Path(
|
|
|
|
|
os.getenv(
|
|
|
|
|
"QONTO_ASSISTANT_POLICY_FILE",
|
|
|
|
|
str(_repo_root() / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return cls(
|
|
|
|
|
service_name=os.getenv("QONTO_ASSISTANT_SERVICE_NAME", "qonto-assistant"),
|
|
|
|
|
default_tenant_id=os.getenv("QONTO_ASSISTANT_DEFAULT_TENANT", "binky"),
|
|
|
|
|
default_actor_lane=os.getenv("QONTO_ASSISTANT_DEFAULT_LANE", "green"),
|
|
|
|
|
required_scope=os.getenv("QONTO_ASSISTANT_REQUIRED_SCOPE", "finance.qonto.read"),
|
|
|
|
|
enforce_scope=os.getenv("QONTO_ASSISTANT_ENFORCE_SCOPE", "false").lower() == "true",
|
|
|
|
|
policy_file=policy_file,
|
|
|
|
|
qonto_base_url=os.getenv("QONTO_BASE_URL", "https://thirdparty.qonto.com"),
|
|
|
|
|
qonto_fixture_dir=(
|
|
|
|
|
Path(os.environ["QONTO_FIXTURE_DIR"]).resolve()
|
|
|
|
|
if os.getenv("QONTO_FIXTURE_DIR")
|
|
|
|
|
else None
|
|
|
|
|
),
|
|
|
|
|
qonto_auth_mode=os.getenv("QONTO_AUTH_MODE", "legacy_api_key"),
|
|
|
|
|
qonto_organization_path=os.getenv("QONTO_ORGANIZATION_PATH", "/v2/organization"),
|
|
|
|
|
qonto_transactions_path=os.getenv("QONTO_TRANSACTIONS_PATH", "/v2/transactions"),
|
|
|
|
|
qonto_timeout_seconds=float(os.getenv("QONTO_TIMEOUT_SECONDS", "10")),
|
|
|
|
|
qonto_max_retries=int(os.getenv("QONTO_MAX_RETRIES", "1")),
|
|
|
|
|
qonto_secret_ttl_seconds=int(os.getenv("QONTO_SECRET_TTL_SECONDS", "300")),
|
|
|
|
|
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")),
|
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
|
|
|
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")),
|
2026-07-24 00:10:41 +02:00
|
|
|
key_cape_jwks_url=os.getenv("QONTO_KEY_CAPE_JWKS_URL") or None,
|
|
|
|
|
key_cape_issuer=os.getenv("QONTO_KEY_CAPE_ISSUER", "https://key-cape.netkingdom"),
|
|
|
|
|
key_cape_audience=os.getenv("QONTO_KEY_CAPE_AUDIENCE", "qonto-assistant"),
|
|
|
|
|
key_cape_required=os.getenv("QONTO_KEY_CAPE_REQUIRED", "false").lower() == "true",
|
|
|
|
|
key_cape_cache_seconds=float(os.getenv("QONTO_KEY_CAPE_CACHE_SECONDS", "300")),
|
|
|
|
|
key_cape_timeout_seconds=float(os.getenv("QONTO_KEY_CAPE_TIMEOUT_SECONDS", "5")),
|
QONTO-WP-0004-T04: live flex-auth + tenant-engine authorization gate
Replaces the config-only QONTO_ASSISTANT_ENFORCE_SCOPE cached-claim
check with two live-checked facts, per docs/SecurityPractice.md #4:
1. flex-auth POST /v1/check on finance.qonto.read for the calling
actor/tenant (FlexAuthCheckClient, modeled on tenant-engine's own
client for the same API). Registration lives in the flex-auth repo
(examples/qonto-assistant/) -- rules + embedded tests verified with
flex-auth test-policy/load-registry/check, and a live flex-auth
serve hit by this exact client over real HTTP (not a mock).
2. tenant-engine's live capability-role lookup
(GET /tenants/{id}/roles/live), denying unless the tenant currently
holds one of QONTO_TENANT_ENGINE_REQUIRED_ROLES (default VEN,CUS) --
optional and additive to the flex-auth check.
Both clients fail closed by construction (unreachable/malformed/non-2xx
all deny, never grant), matching FlexAuthCheckClient's existing
fail-closed philosophy elsewhere in the fleet. LiveAuthorizationGate
combines both and is wired into CapabilityService._execute ahead of
the internal policy kernel; off by default (no QONTO_FLEX_AUTH_URL
set) so existing deployments are unaffected until configured.
Verified beyond mocked unit tests: ran a real `flex-auth serve` loaded
with the registered policy, and a real tenant-engine instance seeded
with a VEN grant for tenant:friendly:binky, and exercised this repo's
actual FlexAuthCheckClient/TenantEngineClient/LiveAuthorizationGate
against both live processes over real HTTP -- allow for the correct
tenant, live_authz_denied for a mismatched tenant.
28 new unit tests (flex_auth_client, tenant_engine_client,
live_authorization_gate + CapabilityService integration). Full suite
-> 80 passed; REST/MCP smokes and compileall still clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:19:04 +02:00
|
|
|
flex_auth_base_url=os.getenv("QONTO_FLEX_AUTH_URL") or None,
|
|
|
|
|
flex_auth_timeout_seconds=float(os.getenv("QONTO_FLEX_AUTH_TIMEOUT_SECONDS", "3")),
|
|
|
|
|
tenant_engine_base_url=os.getenv("QONTO_TENANT_ENGINE_URL") or None,
|
|
|
|
|
tenant_engine_timeout_seconds=float(os.getenv("QONTO_TENANT_ENGINE_TIMEOUT_SECONDS", "3")),
|
|
|
|
|
tenant_engine_required_roles=frozenset(
|
|
|
|
|
role.strip()
|
|
|
|
|
for role in os.getenv("QONTO_TENANT_ENGINE_REQUIRED_ROLES", "VEN,CUS").split(",")
|
|
|
|
|
if role.strip()
|
|
|
|
|
),
|
2026-07-22 21:21:05 +02:00
|
|
|
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"),
|
|
|
|
|
openbao_timeout_seconds=float(os.getenv("QONTO_OPENBAO_TIMEOUT_SECONDS", "5")),
|
2026-07-23 09:41:25 +02:00
|
|
|
mcp_auth_token=os.getenv("QONTO_ASSISTANT_MCP_TOKEN") or None,
|
2026-07-22 21:21:05 +02:00
|
|
|
host=os.getenv("QONTO_ASSISTANT_HOST", "127.0.0.1"),
|
|
|
|
|
port=int(os.getenv("QONTO_ASSISTANT_PORT", "8080")),
|
|
|
|
|
)
|