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:
parent
eef408bb19
commit
ca12843013
33 changed files with 2533 additions and 30 deletions
3
src/qonto_assistant/__init__.py
Normal file
3
src/qonto_assistant/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
175
src/qonto_assistant/app.py
Normal file
175
src/qonto_assistant/app.py
Normal 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,
|
||||
)
|
||||
58
src/qonto_assistant/audit.py
Normal file
58
src/qonto_assistant/audit.py
Normal 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
|
||||
15
src/qonto_assistant/auth.py
Normal file
15
src/qonto_assistant/auth.py
Normal 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)
|
||||
74
src/qonto_assistant/config.py
Normal file
74
src/qonto_assistant/config.py
Normal 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")),
|
||||
)
|
||||
56
src/qonto_assistant/contracts.py
Normal file
56
src/qonto_assistant/contracts.py
Normal 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
|
||||
87
src/qonto_assistant/credentials.py
Normal file
87
src/qonto_assistant/credentials.py
Normal 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}")
|
||||
65
src/qonto_assistant/errors.py
Normal file
65
src/qonto_assistant/errors.py
Normal 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
|
||||
5
src/qonto_assistant/main.py
Normal file
5
src/qonto_assistant/main.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from qonto_assistant.app import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
131
src/qonto_assistant/policy.py
Normal file
131
src/qonto_assistant/policy.py
Normal 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),
|
||||
)
|
||||
27
src/qonto_assistant/policy/qonto-v1.yaml
Normal file
27
src/qonto_assistant/policy/qonto-v1.yaml
Normal 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]
|
||||
246
src/qonto_assistant/qonto_client.py
Normal file
246
src/qonto_assistant/qonto_client.py
Normal 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
|
||||
56
src/qonto_assistant/rate_limits.py
Normal file
56
src/qonto_assistant/rate_limits.py
Normal 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]
|
||||
403
src/qonto_assistant/service.py
Normal file
403
src/qonto_assistant/service.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue