Complete Phase 1: policy kernel, REST service, and local smoke tooling

Implements QONTO-WP-0002 (policy-gated Qonto REST service with audit
logging, rate limiting, and credential handling) and the ADHOC-2026-07-21
follow-up (fixture-backed local smoke mode, repo classification metadata).
Marks QONTO-WP-0001/0002 and the ad-hoc workplan finished, and regenerates
WORK-RECORDS.md and the ADHOC workplan's state_hub_workstream_id via
fix-consistency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-22 21:21:05 +02:00
parent eef408bb19
commit ca12843013
33 changed files with 2533 additions and 30 deletions

View file

@ -0,0 +1,3 @@
__all__ = ["__version__"]
__version__ = "0.1.0"

175
src/qonto_assistant/app.py Normal file
View file

@ -0,0 +1,175 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
import logging
from uuid import uuid4
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.concurrency import run_in_threadpool
from qonto_assistant import __version__
from qonto_assistant.audit import AuditLogger
from qonto_assistant.auth import actor_claims_from_request
from qonto_assistant.config import Settings
from qonto_assistant.credentials import build_credential_provider
from qonto_assistant.errors import QontoAssistantError, UpstreamError
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.service import CapabilityService
def create_app(
*,
settings: Settings | None = None,
service: CapabilityService | None = None,
audit_logger: AuditLogger | None = None,
rate_limiter: RateLimiter | None = None,
concurrency_limiter: ConcurrencyLimiter | None = None,
) -> FastAPI:
settings = settings or Settings.from_env()
audit_logger = audit_logger or AuditLogger()
service = service or _build_service(
settings=settings,
audit_logger=audit_logger,
rate_limiter=rate_limiter,
concurrency_limiter=concurrency_limiter,
)
app = FastAPI(title="qonto-assistant", version=__version__)
app.state.settings = settings
app.state.service = service
@app.exception_handler(QontoAssistantError)
def handle_qonto_error(_: Request, exc: QontoAssistantError) -> JSONResponse:
body = {"error_code": exc.error_code, "detail": exc.message}
if isinstance(exc, UpstreamError) and exc.upstream_status is not None:
body["upstream_status"] = exc.upstream_status
return JSONResponse(status_code=exc.status_code, content=body)
@app.get("/v1/health")
async def health() -> dict[str, str]:
return {
"status": "ok",
"service": settings.service_name,
"version": __version__,
"policy_file": str(settings.policy_file),
}
@app.get("/v1/accounts")
async def get_accounts(request: Request) -> JSONResponse:
claims = actor_claims_from_request(request, settings)
request_id = _request_id(request)
payload = await run_in_threadpool(service.get_accounts, claims=claims, request_id=request_id)
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
@app.get("/v1/transactions")
async def get_transactions(
request: Request,
account_slug: str | None = None,
page: int = 1,
page_size: int = 50,
window_days: int = 31,
status: str | None = "completed",
side: str | None = None,
) -> JSONResponse:
claims = actor_claims_from_request(request, settings)
request_id = _request_id(request)
payload = await run_in_threadpool(
service.list_transactions,
claims=claims,
request_id=request_id,
account_slug=account_slug,
page=page,
page_size=page_size,
window_days=window_days,
status=status,
side=side,
)
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
@app.get("/v1/snapshot")
async def get_snapshot(
request: Request,
window_days: int = 31,
page_size: int = 50,
) -> JSONResponse:
claims = actor_claims_from_request(request, settings)
request_id = _request_id(request)
payload = await run_in_threadpool(
service.get_snapshot,
claims=claims,
request_id=request_id,
window_days=window_days,
page_size=page_size,
)
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
@app.on_event("shutdown")
def shutdown_event() -> None:
with suppress(Exception):
service.client.close()
return app
def _request_id(request: Request) -> str:
existing = getattr(request.state, "request_id", None)
if existing:
return existing
request.state.request_id = request.headers.get("x-request-id", str(uuid4()))
return request.state.request_id
def _build_service(
*,
settings: Settings,
audit_logger: AuditLogger,
rate_limiter: RateLimiter | None,
concurrency_limiter: ConcurrencyLimiter | None,
) -> CapabilityService:
policy = PolicyEngine.from_file(
settings.policy_file,
required_scope=settings.required_scope,
enforce_scope=settings.enforce_scope,
)
if settings.qonto_fixture_dir is not None:
client = FixtureQontoClient(fixture_dir=settings.qonto_fixture_dir)
else:
credential_provider = build_credential_provider(settings)
client = QontoClient(
base_url=settings.qonto_base_url,
organization_path=settings.qonto_organization_path,
transactions_path=settings.qonto_transactions_path,
auth_mode=settings.qonto_auth_mode,
timeout_seconds=settings.qonto_timeout_seconds,
max_retries=settings.qonto_max_retries,
credential_provider=credential_provider,
)
return CapabilityService(
client=client,
policy=policy,
audit_logger=audit_logger,
rate_limiter=rate_limiter
or RateLimiter(
limit=settings.rate_limit_requests,
window_seconds=settings.rate_limit_window_seconds,
),
concurrency_limiter=concurrency_limiter
or ConcurrencyLimiter(limit=settings.max_concurrency),
)
def main() -> None:
settings = Settings.from_env()
logging.basicConfig(level=logging.INFO, format="%(message)s")
uvicorn.run(
"qonto_assistant.app:create_app",
factory=True,
host=settings.host,
port=settings.port,
reload=False,
)

View file

@ -0,0 +1,58 @@
from __future__ import annotations
import json
import logging
from collections.abc import Callable, Mapping
from dataclasses import asdict, is_dataclass
from datetime import UTC, datetime
from typing import Any
from qonto_assistant.contracts import AuditEvent
REDACTED = "[redacted]"
SECRET_KEYS = {
"api_key",
"authorization",
"authorization_header",
"openbao_token",
"secret",
"token",
}
def utc_now_iso() -> str:
return datetime.now(tz=UTC).replace(microsecond=0).isoformat()
def _sanitize(value: Any) -> Any:
if is_dataclass(value):
return _sanitize(asdict(value))
if isinstance(value, Mapping):
return {
key: (REDACTED if key.lower() in SECRET_KEYS else _sanitize(item))
for key, item in value.items()
}
if isinstance(value, list):
return [_sanitize(item) for item in value]
if isinstance(value, tuple):
return [_sanitize(item) for item in value]
return value
class AuditLogger:
def __init__(
self,
*,
logger: logging.Logger | None = None,
sink: Callable[[dict[str, Any]], None] | None = None,
) -> None:
self.logger = logger or logging.getLogger("qonto_assistant.audit")
self.logger.setLevel(logging.INFO)
self.sink = sink
def emit(self, event: AuditEvent | Mapping[str, Any]) -> dict[str, Any]:
payload = _sanitize(asdict(event) if is_dataclass(event) else dict(event))
if self.sink is not None:
self.sink(payload)
self.logger.info(json.dumps(payload, sort_keys=True))
return payload

View file

@ -0,0 +1,15 @@
from __future__ import annotations
from fastapi import Request
from qonto_assistant.config import Settings
from qonto_assistant.contracts import ActorClaims
def actor_claims_from_request(request: Request, settings: Settings) -> ActorClaims:
actor_id = request.headers.get("x-actor-id", "anonymous")
tenant_id = request.headers.get("x-tenant-id", settings.default_tenant_id)
lane = request.headers.get("x-actor-lane", settings.default_actor_lane)
raw_scopes = request.headers.get("x-actor-scopes", "")
scopes = frozenset(scope.strip() for scope in raw_scopes.split(",") if scope.strip())
return ActorClaims(actor_id=actor_id, tenant_id=tenant_id, lane=lane, scopes=scopes)

View file

@ -0,0 +1,74 @@
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
credential_source: str
openbao_path: str
openbao_command: str
openbao_timeout_seconds: float
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")),
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")),
host=os.getenv("QONTO_ASSISTANT_HOST", "127.0.0.1"),
port=int(os.getenv("QONTO_ASSISTANT_PORT", "8080")),
)

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
ProtocolName = Literal["rest", "mcp"]
@dataclass(frozen=True, slots=True)
class ActorClaims:
actor_id: str
tenant_id: str
lane: str = "green"
scopes: frozenset[str] = field(default_factory=frozenset)
@dataclass(frozen=True, slots=True)
class CapabilityRequest:
capability_id: str
tenant_id: str
actor_claims: ActorClaims
resource_scope: str
request_args: dict[str, Any]
protocol: ProtocolName
response_class: str = "operational_summary"
@dataclass(frozen=True, slots=True)
class PolicyDecision:
allowed: bool
capability_id: str
policy_version: int
reason: str
request_args: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class QontoCredentials:
api_user: str
api_key: str
@dataclass(frozen=True, slots=True)
class AuditEvent:
request_id: str
timestamp: str
actor: str
tenant_id: str
capability: str
protocol: ProtocolName
decision: str
deny_reason: str | None
policy_version: int
latency_ms: int
qonto_http_status: int | None = None
result_count: int | None = None

View file

@ -0,0 +1,87 @@
from __future__ import annotations
import os
import subprocess
import time
from collections.abc import Callable
from qonto_assistant.config import Settings
from qonto_assistant.contracts import QontoCredentials
from qonto_assistant.errors import CredentialError
class EnvironmentCredentialProvider:
def get_credentials(self) -> QontoCredentials:
api_key = os.getenv("API_KEY") or os.getenv("QONTO_API_KEY")
api_user = os.getenv("API_USER") or os.getenv("QONTO_ORGANIZATION_ID")
if not api_key or not api_user:
raise CredentialError(
"Missing Qonto credentials in environment (API_KEY/API_USER or QONTO_* vars)"
)
return QontoCredentials(api_user=api_user, api_key=api_key)
def invalidate(self) -> None:
return None
class OpenBaoCliCredentialProvider:
def __init__(
self,
*,
command: str,
path: str,
timeout_seconds: float,
ttl_seconds: int,
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
) -> None:
self.command = command
self.path = path
self.timeout_seconds = timeout_seconds
self.ttl_seconds = ttl_seconds
self.runner = runner
self._cached: tuple[float, QontoCredentials] | None = None
def get_credentials(self) -> QontoCredentials:
now = time.monotonic()
if self._cached is not None and now - self._cached[0] < self.ttl_seconds:
return self._cached[1]
api_key = self._read_field("API_KEY")
api_user = self._read_field("API_USER")
credentials = QontoCredentials(api_user=api_user, api_key=api_key)
self._cached = (now, credentials)
return credentials
def invalidate(self) -> None:
self._cached = None
def _read_field(self, field_name: str) -> str:
result = self.runner(
[self.command, "kv", "get", f"-field={field_name}", self.path],
check=False,
capture_output=True,
text=True,
timeout=self.timeout_seconds,
)
if result.returncode != 0:
raise CredentialError(
f"OpenBao CLI failed while reading {field_name} from {self.path}: "
f"{result.stderr.strip() or result.stdout.strip() or 'unknown error'}"
)
value = result.stdout.strip()
if not value:
raise CredentialError(f"OpenBao returned an empty value for {field_name}")
return value
def build_credential_provider(settings: Settings) -> EnvironmentCredentialProvider | OpenBaoCliCredentialProvider:
if settings.credential_source == "bao-cli":
return OpenBaoCliCredentialProvider(
command=settings.openbao_command,
path=settings.openbao_path,
timeout_seconds=settings.openbao_timeout_seconds,
ttl_seconds=settings.qonto_secret_ttl_seconds,
)
if settings.credential_source == "env":
return EnvironmentCredentialProvider()
raise CredentialError(f"Unsupported credential source: {settings.credential_source}")

View file

@ -0,0 +1,65 @@
from __future__ import annotations
from qonto_assistant.contracts import PolicyDecision
class QontoAssistantError(Exception):
error_code = "internal_error"
status_code = 500
def __init__(self, message: str = "") -> None:
super().__init__(message or self.error_code)
self.message = message or self.error_code
class PolicyDeniedError(QontoAssistantError):
error_code = "policy_denied"
status_code = 403
def __init__(self, decision: PolicyDecision) -> None:
super().__init__(decision.reason)
self.decision = decision
self.error_code = decision.reason
class RateLimitExceededError(QontoAssistantError):
error_code = "rate_limit"
status_code = 429
class ConcurrencyLimitExceededError(QontoAssistantError):
error_code = "concurrency_limit"
status_code = 503
class CredentialError(QontoAssistantError):
error_code = "credential_unavailable"
status_code = 503
class InvalidRequestError(QontoAssistantError):
error_code = "invalid_request"
status_code = 400
def __init__(self, message: str, *, error_code: str = "invalid_request") -> None:
super().__init__(message)
self.error_code = error_code
class UpstreamError(QontoAssistantError):
error_code: str = "upstream_error"
status_code: int = 502
upstream_status: int | None = None
def __init__(
self,
message: str,
*,
error_code: str = "upstream_error",
status_code: int = 502,
upstream_status: int | None = None,
) -> None:
super().__init__(message)
self.error_code = error_code
self.status_code = status_code
self.upstream_status = upstream_status

View file

@ -0,0 +1,5 @@
from qonto_assistant.app import main
if __name__ == "__main__":
main()

View file

@ -0,0 +1,131 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any
import yaml
from qonto_assistant.contracts import CapabilityRequest, PolicyDecision
class PolicyEngine:
def __init__(self, config: Mapping[str, Any], *, required_scope: str, enforce_scope: bool) -> None:
self.config = dict(config)
self.version = int(self.config.get("version", 1))
self.required_scope = required_scope
self.enforce_scope = enforce_scope
self.capabilities = dict(self.config.get("capabilities", {}))
self.deny_classes = dict(self.config.get("deny_classes", {}))
@classmethod
def from_file(cls, path: Path, *, required_scope: str, enforce_scope: bool) -> "PolicyEngine":
with path.open("r", encoding="utf-8") as handle:
config = yaml.safe_load(handle) or {}
return cls(config, required_scope=required_scope, enforce_scope=enforce_scope)
def decide(self, request: CapabilityRequest) -> PolicyDecision:
capability = self.capabilities.get(request.capability_id)
if capability is None:
return self._deny(request, "unknown_capability")
if request.actor_claims.tenant_id != request.tenant_id:
return self._deny(request, "tenant_scope")
if self.enforce_scope and self.required_scope not in request.actor_claims.scopes:
return self._deny(request, "authz_denied")
allowed_lanes = set(capability.get("lanes", []))
if allowed_lanes and request.actor_claims.lane not in allowed_lanes:
return self._deny(request, "authz_denied")
deny_reason = self._match_deny_classes(request)
if deny_reason is not None:
return self._deny(request, deny_reason)
if not self._constraints_ok(capability, request.request_args):
return self._deny(request, "arg_constraint")
return PolicyDecision(
allowed=True,
capability_id=request.capability_id,
policy_version=self.version,
reason="allow",
request_args=dict(request.request_args),
)
def _constraints_ok(self, capability: Mapping[str, Any], request_args: Mapping[str, Any]) -> bool:
constraints = dict(capability.get("constraints", {}))
checks = {
"page_size": ("max_per_page", 1),
"page": ("max_pages_per_call", 1),
"window_days": ("max_window_days", 1),
}
for field_name, (limit_key, minimum) in checks.items():
if field_name not in request_args or request_args[field_name] is None:
continue
try:
numeric = int(request_args[field_name])
except (TypeError, ValueError):
return False
if numeric < minimum:
return False
limit = constraints.get(limit_key)
if limit is not None and numeric > int(limit):
return False
return True
def _match_deny_classes(self, request: CapabilityRequest) -> str | None:
spend_prefixes = tuple(self.deny_classes.get("spend", {}).get("match_prefixes", []))
volume_tags = tuple(self.deny_classes.get("volume_cost", {}).get("match_tags", []))
credential_fields = set(self.deny_classes.get("credential_exfil", {}).get("response_fields", []))
lowered_tokens = {token.lower() for token in self._flatten_strings(request.capability_id, request.request_args)}
for prefix in spend_prefixes:
lowered_prefix = prefix.lower()
if any(token.startswith(lowered_prefix) for token in lowered_tokens):
return "spend"
for tag in volume_tags:
lowered_tag = tag.lower()
if any(lowered_tag in token for token in lowered_tokens):
return "volume_cost"
if request.response_class == "secret":
return "credential_exfil"
requested_fields = request.request_args.get("response_fields", [])
if isinstance(requested_fields, str):
requested_fields = [requested_fields]
if any(str(field).lower() in credential_fields for field in requested_fields):
return "credential_exfil"
if request.request_args.get("include_full_iban") or request.request_args.get("include_api_key"):
return "credential_exfil"
amount_keys = {"amount", "amount_cents", "amount_eur"}
if any(key in request.request_args for key in amount_keys) and request.request_args.get("execute"):
return "spend"
return None
def _flatten_strings(self, capability_id: str, value: Any) -> Iterable[str]:
yield capability_id
if isinstance(value, Mapping):
for key, item in value.items():
yield str(key)
yield from self._flatten_strings(capability_id, item)
elif isinstance(value, list | tuple | set):
for item in value:
yield from self._flatten_strings(capability_id, item)
else:
yield str(value)
def _deny(self, request: CapabilityRequest, reason: str) -> PolicyDecision:
return PolicyDecision(
allowed=False,
capability_id=request.capability_id,
policy_version=self.version,
reason=reason,
request_args=dict(request.request_args),
)

View file

@ -0,0 +1,27 @@
version: 1
default: deny
capabilities:
org_summary:
lanes: [green, blue]
response_class: operational_summary
list_transactions:
lanes: [green, blue]
response_class: operational_summary
constraints:
max_per_page: 100
max_pages_per_call: 5
max_window_days: 93
cost_run_rate_hints:
lanes: [green, blue]
response_class: operational_summary
snapshot_bundle:
lanes: [green, blue]
response_class: operational_summary
compose_only: [org_summary, cost_run_rate_hints]
deny_classes:
spend:
match_prefixes: [create_, issue_, payout_, transfer_, direct_debit_]
volume_cost:
match_tags: [card_operation, invoice_send, payment_link, subscription_change]
credential_exfil:
response_fields: [api_key, authorization_header, full_iban]

View file

@ -0,0 +1,246 @@
from __future__ import annotations
import json
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Protocol
import httpx
from qonto_assistant.contracts import QontoCredentials
from qonto_assistant.credentials import EnvironmentCredentialProvider, OpenBaoCliCredentialProvider
from qonto_assistant.errors import UpstreamError
class QontoClientProtocol(Protocol):
def get_organization(self) -> Mapping[str, Any]: ...
def list_transactions(
self,
*,
iban: str,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> Mapping[str, Any]: ...
def close(self) -> None: ...
class QontoClient:
def __init__(
self,
*,
base_url: str,
organization_path: str,
transactions_path: str,
auth_mode: str,
timeout_seconds: float,
max_retries: int,
credential_provider: EnvironmentCredentialProvider | OpenBaoCliCredentialProvider,
transport: httpx.BaseTransport | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.organization_path = organization_path
self.transactions_path = transactions_path
self.auth_mode = auth_mode
self.max_retries = max_retries
self.credential_provider = credential_provider
self.client = httpx.Client(
base_url=self.base_url,
timeout=httpx.Timeout(timeout_seconds),
transport=transport,
)
def get_organization(self) -> Mapping[str, Any]:
return self._request("GET", self.organization_path)
def list_transactions(
self,
*,
iban: str,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> Mapping[str, Any]:
params: dict[str, Any] = {
"iban": iban,
"current_page": page,
"per_page": page_size,
"window_days": window_days,
}
if status:
params["status"] = status
if side:
params["side"] = side
return self._request("GET", self.transactions_path, params=params)
def close(self) -> None:
self.client.close()
def _request(
self,
method: str,
path: str,
*,
params: Mapping[str, Any] | None = None,
) -> Mapping[str, Any]:
credentials = self.credential_provider.get_credentials()
headers = self._headers(credentials)
attempt = 0
while True:
try:
response = self.client.request(method, path, headers=headers, params=params)
except httpx.TimeoutException as exc:
if attempt < self.max_retries:
attempt += 1
continue
raise UpstreamError("Qonto request timed out", error_code="qonto_timeout", status_code=504) from exc
except httpx.TransportError as exc:
if attempt < self.max_retries:
attempt += 1
continue
raise UpstreamError(
"Qonto transport failure",
error_code="qonto_transport_error",
status_code=502,
) from exc
if response.status_code in {401, 403}:
self.credential_provider.invalidate()
raise UpstreamError(
"Qonto authentication failed",
error_code="qonto_auth_failed",
status_code=502,
upstream_status=response.status_code,
)
if response.status_code >= 500 and attempt < self.max_retries:
attempt += 1
continue
if response.is_error:
raise UpstreamError(
f"Qonto responded with HTTP {response.status_code}",
error_code="qonto_upstream_error",
status_code=502,
upstream_status=response.status_code,
)
payload = response.json()
if not isinstance(payload, Mapping):
raise UpstreamError(
"Qonto returned a non-object payload",
error_code="qonto_invalid_payload",
status_code=502,
upstream_status=response.status_code,
)
return payload
def _headers(self, credentials: QontoCredentials) -> dict[str, str]:
if self.auth_mode == "bearer":
return {"Authorization": f"Bearer {credentials.api_key}"}
if self.auth_mode != "legacy_api_key":
raise UpstreamError(
f"Unsupported auth mode: {self.auth_mode}",
error_code="qonto_auth_mode",
status_code=500,
)
return {"Authorization": f"{credentials.api_user}:{credentials.api_key}"}
class FixtureQontoClient:
def __init__(self, *, fixture_dir: Path) -> None:
self.fixture_dir = fixture_dir
self._organization_payload = self._load("organization.json")
self._transactions_payload = self._load("transactions.json")
def get_organization(self) -> Mapping[str, Any]:
return self._organization_payload
def list_transactions(
self,
*,
iban: str,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> Mapping[str, Any]:
transactions = self._transactions_payload.get("transactions", [])
if not isinstance(transactions, list):
raise UpstreamError(
"Fixture transactions payload is invalid",
error_code="qonto_fixture_invalid",
status_code=500,
)
filtered: list[Mapping[str, Any]] = [item for item in transactions if isinstance(item, Mapping)]
if iban:
filtered = [item for item in filtered if item.get("iban") in {None, "", iban}]
if status:
filtered = [item for item in filtered if item.get("status") == status]
if side:
filtered = [item for item in filtered if item.get("side") == side]
filtered = self._filter_window(filtered, window_days)
start = max((page - 1) * page_size, 0)
end = start + page_size
return {
"transactions": filtered[start:end],
"meta": {
"total": len(filtered),
"page": page,
"page_size": page_size,
"source": "fixture",
},
}
def close(self) -> None:
return None
def _load(self, name: str) -> Mapping[str, Any]:
path = self.fixture_dir / name
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise UpstreamError(
f"Fixture file missing: {path}",
error_code="qonto_fixture_missing",
status_code=500,
) from exc
if not isinstance(payload, Mapping):
raise UpstreamError(
f"Fixture payload must be an object: {path}",
error_code="qonto_fixture_invalid",
status_code=500,
)
return payload
def _filter_window(self, transactions: list[Mapping[str, Any]], window_days: int) -> list[Mapping[str, Any]]:
dated = []
for transaction in transactions:
settled_at = transaction.get("settled_at") or transaction.get("settledAt")
if not settled_at:
dated.append((None, transaction))
continue
dated.append((datetime.fromisoformat(str(settled_at).replace("Z", "+00:00")), transaction))
dates = [item[0] for item in dated if item[0] is not None]
if not dates:
return transactions
reference = max(dates)
filtered: list[Mapping[str, Any]] = []
for parsed, transaction in dated:
if parsed is None:
filtered.append(transaction)
continue
age_days = (reference - parsed).days
if age_days < window_days:
filtered.append(transaction)
return filtered

View file

@ -0,0 +1,56 @@
from __future__ import annotations
import time
from collections import defaultdict, deque
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from threading import Lock
from qonto_assistant.errors import ConcurrencyLimitExceededError, RateLimitExceededError
class RateLimiter:
def __init__(
self,
*,
limit: int,
window_seconds: int,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.limit = limit
self.window_seconds = window_seconds
self.clock = clock
self._events: dict[str, deque[float]] = defaultdict(deque)
self._lock = Lock()
def check(self, key: str) -> None:
now = self.clock()
with self._lock:
window_start = now - self.window_seconds
bucket = self._events[key]
while bucket and bucket[0] < window_start:
bucket.popleft()
if len(bucket) >= self.limit:
raise RateLimitExceededError(f"Rate limit exceeded for {key}")
bucket.append(now)
class ConcurrencyLimiter:
def __init__(self, *, limit: int) -> None:
self.limit = limit
self._counts: dict[str, int] = defaultdict(int)
self._lock = Lock()
@contextmanager
def slot(self, key: str) -> Iterator[None]:
with self._lock:
if self._counts[key] >= self.limit:
raise ConcurrencyLimitExceededError(f"Concurrency limit exceeded for {key}")
self._counts[key] += 1
try:
yield
finally:
with self._lock:
self._counts[key] -= 1
if self._counts[key] <= 0:
del self._counts[key]

View file

@ -0,0 +1,403 @@
from __future__ import annotations
import time
from collections import defaultdict
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from qonto_assistant.audit import AuditLogger, utc_now_iso
from qonto_assistant.contracts import ActorClaims, AuditEvent, CapabilityRequest
from qonto_assistant.errors import InvalidRequestError, PolicyDeniedError, UpstreamError
from qonto_assistant.policy import PolicyEngine
from qonto_assistant.qonto_client import QontoClientProtocol
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
class CapabilityService:
def __init__(
self,
*,
client: QontoClientProtocol,
policy: PolicyEngine,
audit_logger: AuditLogger,
rate_limiter: RateLimiter,
concurrency_limiter: ConcurrencyLimiter,
) -> None:
self.client = client
self.policy = policy
self.audit_logger = audit_logger
self.rate_limiter = rate_limiter
self.concurrency_limiter = concurrency_limiter
def get_accounts(self, *, claims: ActorClaims, request_id: str) -> dict[str, Any]:
return self._execute(
capability_id="org_summary",
claims=claims,
request_args={},
resource_scope="accounts",
request_id=request_id,
operation=self._build_accounts_payload,
)
def list_transactions(
self,
*,
claims: ActorClaims,
request_id: str,
account_slug: str | None,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> dict[str, Any]:
return self._execute(
capability_id="list_transactions",
claims=claims,
request_args={
"account_slug": account_slug,
"page": page,
"page_size": page_size,
"window_days": window_days,
"status": status,
"side": side,
},
resource_scope="transactions",
request_id=request_id,
operation=lambda _: self._build_transactions_payload(
account_slug=account_slug,
page=page,
page_size=page_size,
window_days=window_days,
status=status,
side=side,
),
)
def get_snapshot(
self,
*,
claims: ActorClaims,
request_id: str,
window_days: int,
page_size: int,
) -> dict[str, Any]:
return self._execute(
capability_id="snapshot_bundle",
claims=claims,
request_args={"window_days": window_days, "page_size": page_size},
resource_scope="snapshot",
request_id=request_id,
operation=lambda _: self._build_snapshot_payload(window_days=window_days, page_size=page_size),
)
def _execute(
self,
*,
capability_id: str,
claims: ActorClaims,
request_args: dict[str, Any],
resource_scope: str,
request_id: str,
operation,
) -> dict[str, Any]:
request = CapabilityRequest(
capability_id=capability_id,
tenant_id=claims.tenant_id,
actor_claims=claims,
resource_scope=resource_scope,
request_args=request_args,
protocol="rest",
)
started = time.perf_counter()
decision = self.policy.decide(request)
if not decision.allowed:
self._emit_audit(
request_id=request_id,
claims=claims,
capability_id=capability_id,
decision="deny",
deny_reason=decision.reason,
latency_ms=_latency_ms(started),
result_count=None,
qonto_http_status=None,
)
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):
payload = operation(decision.request_args)
self._emit_audit(
request_id=request_id,
claims=claims,
capability_id=capability_id,
decision="allow",
deny_reason=None,
latency_ms=_latency_ms(started),
result_count=_result_count(payload),
qonto_http_status=200,
)
return payload
def _build_accounts_payload(self, _: Mapping[str, Any]) -> dict[str, Any]:
organization_payload = self.client.get_organization()
organization = _extract_organization(organization_payload)
accounts = [_normalize_account(account) for account in _extract_accounts(organization_payload)]
return {
"organization": _normalize_organization(organization),
"accounts": accounts,
"totals": {
"balance": round(sum(account["balance"] for account in accounts), 2),
"authorized_balance": round(sum(account["authorized_balance"] for account in accounts), 2),
},
}
def _build_transactions_payload(
self,
*,
account_slug: str | None,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> dict[str, Any]:
organization_payload = self.client.get_organization()
organization = _extract_organization(organization_payload)
accounts = _extract_accounts(organization_payload)
selected_account = _select_account(accounts, account_slug)
raw_transactions = self.client.list_transactions(
iban=selected_account["iban"],
page=page,
page_size=page_size,
window_days=window_days,
status=status,
side=side,
)
transactions = [_normalize_transaction(item) for item in _extract_transactions(raw_transactions)]
return {
"organization": _normalize_organization(organization),
"account": _normalize_account(selected_account),
"page": page,
"page_size": page_size,
"window_days": window_days,
"transactions": transactions,
}
def _build_snapshot_payload(self, *, window_days: int, page_size: int) -> dict[str, Any]:
accounts_payload = self._build_accounts_payload({})
accounts = accounts_payload["accounts"]
main_account = next((account for account in accounts if account["main"]), accounts[0] if accounts else None)
recent_transactions = []
if main_account is not None:
transactions_payload = self._build_transactions_payload(
account_slug=main_account["slug"],
page=1,
page_size=min(page_size, 50),
window_days=window_days,
status="completed",
side=None,
)
recent_transactions = transactions_payload["transactions"]
return {
"organization": accounts_payload["organization"],
"accounts": accounts,
"summary": {
"total_balance": accounts_payload["totals"]["balance"],
"authorized_balance": accounts_payload["totals"]["authorized_balance"],
"window_days": window_days,
},
"cost_run_rate_hints": _build_cost_run_rate_hints(recent_transactions),
"recent_transactions": recent_transactions[:10],
}
def _emit_audit(
self,
*,
request_id: str,
claims: ActorClaims,
capability_id: str,
decision: str,
deny_reason: str | None,
latency_ms: int,
result_count: int | None,
qonto_http_status: int | None,
) -> None:
event = AuditEvent(
request_id=request_id,
timestamp=utc_now_iso(),
actor=claims.actor_id,
tenant_id=claims.tenant_id,
capability=capability_id,
protocol="rest",
decision=decision,
deny_reason=deny_reason,
policy_version=self.policy.version,
latency_ms=latency_ms,
qonto_http_status=qonto_http_status,
result_count=result_count,
)
self.audit_logger.emit(event)
def _latency_ms(started: float) -> int:
return int((time.perf_counter() - started) * 1000)
def _extract_organization(payload: Mapping[str, Any]) -> Mapping[str, Any]:
organization = payload.get("organization", payload)
if not isinstance(organization, Mapping):
raise ValueError("Organization payload is missing or invalid")
return organization
def _extract_accounts(payload: Mapping[str, Any]) -> list[Mapping[str, Any]]:
organization = _extract_organization(payload)
accounts = organization.get("bank_accounts") or payload.get("bank_accounts") or []
if not isinstance(accounts, list):
raise ValueError("Bank accounts payload is invalid")
return [account for account in accounts if isinstance(account, Mapping)]
def _extract_transactions(payload: Mapping[str, Any]) -> list[Mapping[str, Any]]:
transactions = payload.get("transactions") or payload.get("items") or []
if not isinstance(transactions, list):
raise ValueError("Transactions payload is invalid")
return [transaction for transaction in transactions if isinstance(transaction, Mapping)]
def _normalize_organization(raw: Mapping[str, Any]) -> dict[str, Any]:
return {
"name": raw.get("name"),
"legal_name": raw.get("legal_name") or raw.get("legalName") or raw.get("name"),
"slug": raw.get("slug"),
"legal_country": raw.get("legal_country") or raw.get("legalCountry"),
"legal_registration_date": raw.get("legal_registration_date")
or raw.get("legalRegistrationDate"),
}
def _normalize_account(raw: Mapping[str, Any]) -> dict[str, Any]:
iban = str(raw.get("iban", ""))
return {
"name": raw.get("name"),
"slug": raw.get("slug"),
"currency": raw.get("currency", "EUR"),
"balance": _amount_value(raw),
"authorized_balance": _amount_value(raw, "authorized_balance"),
"iban_last4": raw.get("iban_last4") or (iban[-4:] if iban else None),
"main": bool(raw.get("main", False)),
"status": raw.get("status", "unknown"),
}
def _normalize_transaction(raw: Mapping[str, Any]) -> dict[str, Any]:
settled_at = raw.get("settled_at") or raw.get("settledAt") or raw.get("updated_at")
return {
"id": raw.get("id") or raw.get("transaction_id"),
"date": (str(settled_at)[:10] if settled_at else None),
"label": raw.get("label") or raw.get("counterparty_name") or raw.get("name"),
"side": raw.get("side"),
"amount": _amount_value(raw),
"currency": raw.get("currency", "EUR"),
"category": raw.get("category"),
"operation_type": raw.get("operation_type") or raw.get("operationType"),
"status": raw.get("status"),
}
def _amount_value(raw: Mapping[str, Any], key: str = "balance") -> float:
amount = raw.get(key)
if amount is None and key == "balance":
amount = raw.get("amount")
if amount is None and key == "authorized_balance":
amount = raw.get("authorized_balance")
if amount is None:
cents = raw.get("amount_cents") or raw.get("amountCents")
if cents is None:
return 0.0
return round(float(cents) / 100, 2)
return round(float(amount), 2)
def _select_account(accounts: list[Mapping[str, Any]], account_slug: str | None) -> Mapping[str, Any]:
if not accounts:
raise UpstreamError(
"No accounts available in organization payload",
error_code="qonto_invalid_payload",
status_code=502,
)
if account_slug:
for account in accounts:
if account.get("slug") == account_slug:
return account
raise InvalidRequestError(f"Unknown account slug: {account_slug}", error_code="resource_scope")
for account in accounts:
if account.get("main"):
return account
return accounts[0]
def _build_cost_run_rate_hints(transactions: list[dict[str, Any]]) -> dict[str, Any]:
recurring: dict[tuple[str, str, float], list[dict[str, Any]]] = defaultdict(list)
for transaction in transactions:
if transaction.get("side") != "debit" or transaction.get("status") != "completed":
continue
key = (
str(transaction.get("label") or "unknown"),
str(transaction.get("operation_type") or "unknown"),
float(transaction.get("amount") or 0.0),
)
recurring[key].append(transaction)
recurring_debits = []
for (label, operation_type, amount), items in recurring.items():
if len(items) < 2:
continue
observed_months = sorted(
{
datetime.fromisoformat(f"{item['date']}T00:00:00+00:00")
.astimezone(UTC)
.strftime("%Y-%m")
for item in items
if item.get("date")
}
)
recurring_debits.append(
{
"label": label,
"operation_type": operation_type,
"amount": amount,
"occurrences": len(items),
"observed_months": observed_months,
"latest_date": max(item["date"] for item in items if item.get("date")),
}
)
recurring_debits.sort(key=lambda item: (-item["amount"], item["label"]))
total_debits = round(
sum(float(transaction["amount"]) for transaction in transactions if transaction.get("side") == "debit"),
2,
)
total_credits = round(
sum(float(transaction["amount"]) for transaction in transactions if transaction.get("side") == "credit"),
2,
)
return {
"recurring_debits": recurring_debits[:10],
"total_debits": total_debits,
"total_credits": total_credits,
}
def _result_count(payload: Mapping[str, Any]) -> int | None:
for key in ("transactions", "accounts", "recent_transactions"):
value = payload.get(key)
if isinstance(value, list):
return len(value)
return None