From ca12843013fed2f14332c50cfdcd4c93990a36fc Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 22 Jul 2026 21:21:05 +0200 Subject: [PATCH] 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 --- .repo-classification.yaml | 26 ++ AGENTS.md | 26 +- Makefile | 33 ++ README.md | 27 +- WORK-RECORDS.md | 21 +- docs/operator-runbook.md | 183 ++++++++ pyproject.toml | 48 +++ scripts/smoke_rest_api.py | 127 ++++++ src/qonto_assistant/__init__.py | 3 + src/qonto_assistant/app.py | 175 ++++++++ src/qonto_assistant/audit.py | 58 +++ src/qonto_assistant/auth.py | 15 + src/qonto_assistant/config.py | 74 ++++ src/qonto_assistant/contracts.py | 56 +++ src/qonto_assistant/credentials.py | 87 ++++ src/qonto_assistant/errors.py | 65 +++ src/qonto_assistant/main.py | 5 + src/qonto_assistant/policy.py | 131 ++++++ src/qonto_assistant/policy/qonto-v1.yaml | 27 ++ src/qonto_assistant/qonto_client.py | 246 +++++++++++ src/qonto_assistant/rate_limits.py | 56 +++ src/qonto_assistant/service.py | 403 ++++++++++++++++++ tests/fixtures/qonto/organization.json | 31 ++ tests/fixtures/qonto/transactions.json | 64 +++ tests/test_api.py | 213 +++++++++ tests/test_audit.py | 25 ++ tests/test_fixture_client.py | 32 ++ tests/test_policy.py | 87 ++++ tests/test_qonto_client.py | 84 ++++ tests/test_snapshot_semantics.py | 53 +++ workplans/ADHOC-2026-07-21.md | 51 +++ workplans/QONTO-WP-0001-statehub-bootstrap.md | 7 +- .../QONTO-WP-0002-policy-kernel-and-rest.md | 24 +- 33 files changed, 2533 insertions(+), 30 deletions(-) create mode 100644 .repo-classification.yaml create mode 100644 Makefile create mode 100644 docs/operator-runbook.md create mode 100644 pyproject.toml create mode 100644 scripts/smoke_rest_api.py create mode 100644 src/qonto_assistant/__init__.py create mode 100644 src/qonto_assistant/app.py create mode 100644 src/qonto_assistant/audit.py create mode 100644 src/qonto_assistant/auth.py create mode 100644 src/qonto_assistant/config.py create mode 100644 src/qonto_assistant/contracts.py create mode 100644 src/qonto_assistant/credentials.py create mode 100644 src/qonto_assistant/errors.py create mode 100644 src/qonto_assistant/main.py create mode 100644 src/qonto_assistant/policy.py create mode 100644 src/qonto_assistant/policy/qonto-v1.yaml create mode 100644 src/qonto_assistant/qonto_client.py create mode 100644 src/qonto_assistant/rate_limits.py create mode 100644 src/qonto_assistant/service.py create mode 100644 tests/fixtures/qonto/organization.json create mode 100644 tests/fixtures/qonto/transactions.json create mode 100644 tests/test_api.py create mode 100644 tests/test_audit.py create mode 100644 tests/test_fixture_client.py create mode 100644 tests/test_policy.py create mode 100644 tests/test_qonto_client.py create mode 100644 tests/test_snapshot_semantics.py create mode 100644 workplans/ADHOC-2026-07-21.md diff --git a/.repo-classification.yaml b/.repo-classification.yaml new file mode 100644 index 0000000..6022e2d --- /dev/null +++ b/.repo-classification.yaml @@ -0,0 +1,26 @@ +repo_classification: + standard: Repo Classification Standard + version: '1.0' + classified_at: '2026-07-21' + classified_by: agent + category: tooling + domain: infotech + secondary_domains: + - financials + - agents + capability_tags: + - finance + - governance + - automation + - api + - observability + business_stake: + - technology + - operations + - finance + - automation + business_mechanics: + - control + - coordination + - operation + notes: Governed Qonto domain service for read-only finance awareness across agent harnesses. diff --git a/AGENTS.md b/AGENTS.md index b4a3153..41cb4fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,18 +126,28 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ `tenants/binky/qonto-api` (`API_KEY`, `API_USER`). Clients use assistant URL + workload identity only. Policy v1: **no spend / no volume-cost**. -**Local verification (until QONTO-WP-0002 lands runtime):** +**Local verification (Phase 1 runtime landed):** + +Preferred, when `make` and local venv bootstrapping are available: ```bash -# Workplan frontmatter ↔ hub -statehub fix-consistency - -# Docs present -test -f INTENT.md && test -f specs/ArchitectureBlueprint.md +make install-dev +make test +make lint +make run ``` -After Phase 1 skeleton exists, prefer `make test` / `make lint` as defined in -the Makefile from QONTO-WP-0002-T01. +Verified fallback on this workstation: + +```bash +# Reuse a sibling fleet venv that already contains FastAPI/httpx/pytest +PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest + +# Syntax-only fallback when ruff/dev deps are unavailable +python3 -m compileall src tests +``` + +Operational details live in `docs/operator-runbook.md`. --- diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3f2c917 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +VENV ?= .venv +PYTHON ?= $(VENV)/bin/python +PIP ?= $(PYTHON) -m pip +PYTEST ?= $(PYTHON) -m pytest +RUFF ?= $(VENV)/bin/ruff + +.PHONY: help install-dev test lint run + +help: + @echo "make install-dev Create .venv and install runtime + dev dependencies" + @echo "make test Run the Phase 1 test suite" + @echo "make lint Run syntax and static checks" + @echo "make run Start the local REST API on 127.0.0.1:8080" + +$(VENV)/bin/python: + python3 -m venv $(VENV) + +$(VENV)/.dev-installed: pyproject.toml $(VENV)/bin/python + $(PIP) install --upgrade pip + $(PIP) install -e ".[dev]" + @touch $(VENV)/.dev-installed + +install-dev: $(VENV)/.dev-installed + +test: $(VENV)/.dev-installed + $(PYTEST) + +lint: $(VENV)/.dev-installed + $(PYTHON) -m compileall src tests + $(RUFF) check src tests + +run: $(VENV)/.dev-installed + $(PYTHON) -m qonto_assistant.main diff --git a/README.md b/README.md index 6bb080f..8363bb3 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,35 @@ no spend, no volume-cost actions.** | [`INTENT.md`](INTENT.md) | Why this exists; boundaries | | [`specs/ArchitectureBlueprint.md`](specs/ArchitectureBlueprint.md) | Architecture, phases, policy model | | [`research/2026-07-21-mcp-gateway-and-governed-domain-assistant.md`](research/2026-07-21-mcp-gateway-and-governed-domain-assistant.md) | External + internal research | +| [`docs/operator-runbook.md`](docs/operator-runbook.md) | How to run and verify Phase 1 | ## Status -Scaffold + intent + blueprint. Implementation tracked in `workplans/`. +Phase 1 runtime is implemented: + +- Python 3.12 service under `src/qonto_assistant/` +- default-deny YAML policy +- Qonto read-only client (`organization`, `transactions`) +- REST endpoints: `/v1/health`, `/v1/accounts`, `/v1/transactions`, `/v1/snapshot` +- audit metadata, rate limiting, concurrency bounds, tests + +Current verification: + +- `PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest` → `16 passed` +- `python3 -m compileall src tests scripts` +- `../state-hub/.venv/bin/python scripts/smoke_rest_api.py --python ../state-hub/.venv/bin/python` + +Local fixture-backed smoke mode is available through `QONTO_FIXTURE_DIR`, so the +service can be exercised without real Qonto credentials. The smoke path checks +both a `31`-day recent snapshot and a `90`-day recurring-cost snapshot. + +Normal local workflow, when toolchain support exists: + +```bash +make install-dev +make test +make run +``` ## Related diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index cf51cae..fc546e2 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -8,15 +8,18 @@ | Kind | ID | Status | Lane | Source | | --- | --- | --- | --- | --- | +| workplan | ADHOC-2026-07-21 | finished | — | workplans/ADHOC-2026-07-21.md | | workplan | QONTO-WP-0001 | finished | — | workplans/QONTO-WP-0001-statehub-bootstrap.md | -| workplan | QONTO-WP-0002 | ready | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | +| workplan | QONTO-WP-0002 | finished | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | +| task | ADHOC-2026-07-21-T01 | done | — | workplans/ADHOC-2026-07-21.md | +| task | ADHOC-2026-07-21-T02 | done | — | workplans/ADHOC-2026-07-21.md | | task | QONTO-WP-0001-T01 | done | — | workplans/QONTO-WP-0001-statehub-bootstrap.md | -| task | QONTO-WP-0001-T02 | wait | — | workplans/QONTO-WP-0001-statehub-bootstrap.md | +| task | QONTO-WP-0001-T02 | done | — | workplans/QONTO-WP-0001-statehub-bootstrap.md | | task | QONTO-WP-0001-T03 | done | — | workplans/QONTO-WP-0001-statehub-bootstrap.md | -| task | QONTO-WP-0002-T01 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | -| task | QONTO-WP-0002-T02 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | -| task | QONTO-WP-0002-T03 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | -| task | QONTO-WP-0002-T04 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | -| task | QONTO-WP-0002-T05 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | -| task | QONTO-WP-0002-T06 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | -| task | QONTO-WP-0002-T07 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | +| task | QONTO-WP-0002-T01 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | +| task | QONTO-WP-0002-T02 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | +| task | QONTO-WP-0002-T03 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | +| task | QONTO-WP-0002-T04 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | +| task | QONTO-WP-0002-T05 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | +| task | QONTO-WP-0002-T06 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | +| task | QONTO-WP-0002-T07 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md | diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md new file mode 100644 index 0000000..5318555 --- /dev/null +++ b/docs/operator-runbook.md @@ -0,0 +1,183 @@ +# Qonto Assistant Operator Runbook + +## What this Phase 1 service does + +`qonto-assistant` is the read-only REST surface for governed Qonto access. +Phase 1 ships: + +- policy-gated `GET /v1/accounts` +- policy-gated `GET /v1/transactions` +- policy-gated `GET /v1/snapshot` +- structured audit events without secrets +- env-backed or OpenBao-CLI-backed credential loading + +Spend, transfer, card, invoicing, payment-link, and other volume-cost actions +remain denied by policy. + +## Preferred local workflow + +If `make` and `python3 -m venv` are available: + +```bash +make install-dev +make test +make run +``` + +The API then listens on `http://127.0.0.1:8080`. + +## Verified fallback on this workstation + +This workstation currently lacks: + +- `make` +- `python3 -m venv` support (`ensurepip` missing) +- `python3 -m pip` + +Use an existing fleet virtualenv that already contains FastAPI/httpx/pytest: + +```bash +PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest +python3 -m compileall src tests +``` + +This fallback was used to verify the current implementation. + +## Credential sources + +### Option A: env-injected credentials + +Provide either: + +- `API_USER` + `API_KEY` +- or `QONTO_ORGANIZATION_ID` + `QONTO_API_KEY` + +Example: + +```bash +export API_USER='...' +export API_KEY='...' +``` + +### Option B: OpenBao CLI fetch inside the service + +Set: + +```bash +export QONTO_CREDENTIAL_SOURCE=bao-cli +export QONTO_OPENBAO_PATH=tenants/binky/qonto-api +export QONTO_OPENBAO_COMMAND=bao +``` + +The service then shells out to `bao kv get -field=...` and caches the +credentials in memory for a short TTL. + +## Start the API + +Preferred: + +```bash +make run +``` + +Fallback: + +```bash +PYTHONPATH=src ../state-hub/.venv/bin/python -m qonto_assistant.main +``` + +## Fixture-backed local mode + +For local smoke work without real bank credentials: + +```bash +export QONTO_FIXTURE_DIR=tests/fixtures/qonto +PYTHONPATH=src ../state-hub/.venv/bin/python -m qonto_assistant.main +``` + +In this mode the service serves canned Qonto organization and transaction +payloads from `tests/fixtures/qonto/`. + +## One-command HTTP smoke + +```bash +../state-hub/.venv/bin/python scripts/smoke_rest_api.py \ + --python ../state-hub/.venv/bin/python +``` + +This starts the service on a random local port against the fixture payloads, +checks `/v1/health`, `/v1/accounts`, a recent `31`-day snapshot, and a wider +`90`-day cost-review snapshot, then shuts the process down. + +## Example calls + +Minimal local call: + +```bash +python3 - <<'PY' +import json +import urllib.request + +req = urllib.request.Request( + "http://127.0.0.1:8080/v1/accounts", + headers={"X-Actor-ID": "local-operator", "X-Tenant-ID": "binky"}, +) +with urllib.request.urlopen(req, timeout=10) as resp: + print(json.dumps(json.load(resp), indent=2)) +PY +``` + +Transactions view: + +```bash +python3 - <<'PY' +import json +import urllib.request + +req = urllib.request.Request( + "http://127.0.0.1:8080/v1/transactions?page_size=50&window_days=31", + headers={"X-Actor-ID": "finance-steward", "X-Tenant-ID": "binky"}, +) +with urllib.request.urlopen(req, timeout=10) as resp: + print(json.dumps(json.load(resp), indent=2)) +PY +``` + +Snapshot for CostRunRate refresh: + +```bash +python3 - <<'PY' +import json +import urllib.request + +req = urllib.request.Request( + "http://127.0.0.1:8080/v1/snapshot?window_days=90&page_size=50", + headers={"X-Actor-ID": "finance-steward", "X-Tenant-ID": "binky"}, +) +with urllib.request.urlopen(req, timeout=10) as resp: + print(json.dumps(json.load(resp), indent=2)) +PY +``` + +Use `window_days=31` for a recent-activity view. Use `window_days=90` or `93` +when recurring fixed-cost hints are required. + +## CostRunRate refresh path + +`binky-control` should consume `GET /v1/snapshot` and extract: + +- redacted organization/account summary +- recent transactions +- recurring debit hints for fixed-cost review + +The repo does not write directly into `binky-control/finance/CostRunRate.md`. +That consumer-side write remains outside this repo. + +## Safety notes + +- Never print or commit `API_KEY`. +- Prefer `X-Tenant-ID: binky` explicitly even in single-tenant dogfood. +- Audit output is metadata-only; account identifiers stay redacted by default. +- The service supports a `bearer` auth mode for future upstream evolution, but + the current dogfood path remains `legacy_api_key` because that is the proven + BINKY-WP-0005 header mode. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c23844d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "qonto-assistant" +version = "0.1.0" +description = "Governed Qonto read-only REST assistant for multi-harness finance awareness." +readme = "README.md" +requires-python = ">=3.12" +license = { file = "LICENSE" } +authors = [{ name = "Coulomb" }] +dependencies = [ + "fastapi>=0.115,<1.0", + "httpx>=0.27,<1.0", + "PyYAML>=6.0,<7.0", + "uvicorn[standard]>=0.30,<1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.2,<9.0", + "ruff>=0.6,<1.0", +] + +[project.scripts] +qonto-assistant = "qonto_assistant.main:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +qonto_assistant = ["policy/*.yaml"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = [ + "--strict-markers", + "--disable-warnings", + "--tb=short", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "B"] diff --git a/scripts/smoke_rest_api.py b/scripts/smoke_rest_api.py new file mode 100644 index 0000000..d7195eb --- /dev/null +++ b/scripts/smoke_rest_api.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a local HTTP smoke test for qonto-assistant.") + parser.add_argument( + "--python", + default=sys.executable, + help="Python interpreter used to start qonto_assistant.main", + ) + parser.add_argument( + "--fixture-dir", + default=str(Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "qonto"), + help="Fixture directory containing organization.json and transactions.json", + ) + parser.add_argument( + "--startup-timeout", + type=float, + default=15.0, + help="Seconds to wait for the local API to start", + ) + return parser.parse_args() + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def request_json(url: str, *, headers: dict[str, str] | None = None) -> dict[str, object]: + request = urllib.request.Request(url, headers=headers or {}) + with urllib.request.urlopen(request, timeout=5) as response: + return json.load(response) + + +def wait_for_health(base_url: str, *, timeout_seconds: float) -> dict[str, object]: + deadline = time.monotonic() + timeout_seconds + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + return request_json(f"{base_url}/v1/health") + except Exception as exc: # noqa: BLE001 + last_error = exc + time.sleep(0.25) + raise RuntimeError(f"Timed out waiting for {base_url}/v1/health: {last_error}") + + +def main() -> int: + args = parse_args() + repo_root = Path(__file__).resolve().parents[1] + port = free_port() + base_url = f"http://127.0.0.1:{port}" + + env = os.environ.copy() + env["PYTHONPATH"] = str(repo_root / "src") + env["QONTO_ASSISTANT_HOST"] = "127.0.0.1" + env["QONTO_ASSISTANT_PORT"] = str(port) + env["QONTO_FIXTURE_DIR"] = str(Path(args.fixture_dir).resolve()) + + process = subprocess.Popen( # noqa: S603 + [args.python, "-m", "qonto_assistant.main"], + cwd=repo_root, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + health = wait_for_health(base_url, timeout_seconds=args.startup_timeout) + headers = {"X-Actor-ID": "smoke", "X-Tenant-ID": "binky"} + accounts = request_json(f"{base_url}/v1/accounts", headers=headers) + snapshot_recent = request_json( + f"{base_url}/v1/snapshot?window_days=31&page_size=50", + headers=headers, + ) + snapshot_cost = request_json( + f"{base_url}/v1/snapshot?window_days=90&page_size=50", + headers=headers, + ) + + assert health["status"] == "ok" + assert accounts["organization"]["name"] == "Binky Hedgehog GmbH" + assert accounts["accounts"][0]["iban_last4"] == "6810" + assert snapshot_recent["cost_run_rate_hints"]["recurring_debits"] == [] + assert snapshot_cost["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31" + + print( + json.dumps( + { + "health": health, + "accounts": accounts, + "snapshot_recent": snapshot_recent, + "snapshot_cost": snapshot_cost, + }, + indent=2, + ) + ) + return 0 + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + if process.stdout is not None: + output = process.stdout.read().strip() + if output: + sys.stderr.write(output + "\n") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/qonto_assistant/__init__.py b/src/qonto_assistant/__init__.py new file mode 100644 index 0000000..a05eb9a --- /dev/null +++ b/src/qonto_assistant/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/src/qonto_assistant/app.py b/src/qonto_assistant/app.py new file mode 100644 index 0000000..f0115e0 --- /dev/null +++ b/src/qonto_assistant/app.py @@ -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, + ) diff --git a/src/qonto_assistant/audit.py b/src/qonto_assistant/audit.py new file mode 100644 index 0000000..9ab8a6d --- /dev/null +++ b/src/qonto_assistant/audit.py @@ -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 diff --git a/src/qonto_assistant/auth.py b/src/qonto_assistant/auth.py new file mode 100644 index 0000000..d05e229 --- /dev/null +++ b/src/qonto_assistant/auth.py @@ -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) diff --git a/src/qonto_assistant/config.py b/src/qonto_assistant/config.py new file mode 100644 index 0000000..53770a6 --- /dev/null +++ b/src/qonto_assistant/config.py @@ -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")), + ) diff --git a/src/qonto_assistant/contracts.py b/src/qonto_assistant/contracts.py new file mode 100644 index 0000000..074fe67 --- /dev/null +++ b/src/qonto_assistant/contracts.py @@ -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 diff --git a/src/qonto_assistant/credentials.py b/src/qonto_assistant/credentials.py new file mode 100644 index 0000000..e2fb6c0 --- /dev/null +++ b/src/qonto_assistant/credentials.py @@ -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}") diff --git a/src/qonto_assistant/errors.py b/src/qonto_assistant/errors.py new file mode 100644 index 0000000..56630ab --- /dev/null +++ b/src/qonto_assistant/errors.py @@ -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 diff --git a/src/qonto_assistant/main.py b/src/qonto_assistant/main.py new file mode 100644 index 0000000..0de158b --- /dev/null +++ b/src/qonto_assistant/main.py @@ -0,0 +1,5 @@ +from qonto_assistant.app import main + + +if __name__ == "__main__": + main() diff --git a/src/qonto_assistant/policy.py b/src/qonto_assistant/policy.py new file mode 100644 index 0000000..6efc0ba --- /dev/null +++ b/src/qonto_assistant/policy.py @@ -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), + ) diff --git a/src/qonto_assistant/policy/qonto-v1.yaml b/src/qonto_assistant/policy/qonto-v1.yaml new file mode 100644 index 0000000..da97673 --- /dev/null +++ b/src/qonto_assistant/policy/qonto-v1.yaml @@ -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] diff --git a/src/qonto_assistant/qonto_client.py b/src/qonto_assistant/qonto_client.py new file mode 100644 index 0000000..6d510c3 --- /dev/null +++ b/src/qonto_assistant/qonto_client.py @@ -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 diff --git a/src/qonto_assistant/rate_limits.py b/src/qonto_assistant/rate_limits.py new file mode 100644 index 0000000..0051e48 --- /dev/null +++ b/src/qonto_assistant/rate_limits.py @@ -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] diff --git a/src/qonto_assistant/service.py b/src/qonto_assistant/service.py new file mode 100644 index 0000000..83c7235 --- /dev/null +++ b/src/qonto_assistant/service.py @@ -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 diff --git a/tests/fixtures/qonto/organization.json b/tests/fixtures/qonto/organization.json new file mode 100644 index 0000000..b234f5d --- /dev/null +++ b/tests/fixtures/qonto/organization.json @@ -0,0 +1,31 @@ +{ + "organization": { + "name": "Binky Hedgehog GmbH", + "legal_name": "Binky Hedgehog GmbH", + "slug": "binky-hedgehog-gmbh-6923", + "legal_country": "DE", + "legal_registration_date": "2019-03-15", + "bank_accounts": [ + { + "name": "Hauptkonto", + "slug": "main-account", + "currency": "EUR", + "balance": 2185.94, + "authorized_balance": 2185.94, + "iban": "DE02100100101234566810", + "main": true, + "status": "active" + }, + { + "name": "Kickstart Business", + "slug": "secondary-account", + "currency": "EUR", + "balance": 0.0, + "authorized_balance": 0.0, + "iban": "DE02100100101234567038", + "main": false, + "status": "active" + } + ] + } +} diff --git a/tests/fixtures/qonto/transactions.json b/tests/fixtures/qonto/transactions.json new file mode 100644 index 0000000..80bb2eb --- /dev/null +++ b/tests/fixtures/qonto/transactions.json @@ -0,0 +1,64 @@ +{ + "transactions": [ + { + "id": "tx-qonto-2026-07", + "settled_at": "2026-07-01T08:00:00Z", + "label": "Qonto", + "side": "debit", + "amount": 70.8, + "currency": "EUR", + "category": "subscription", + "operation_type": "qonto_fee", + "status": "completed", + "iban": "DE02100100101234566810" + }, + { + "id": "tx-hub31-2026-06", + "settled_at": "2026-06-02T08:00:00Z", + "label": "HUB31", + "side": "debit", + "amount": 297.5, + "currency": "EUR", + "category": "other_expense", + "operation_type": "transfer", + "status": "completed", + "iban": "DE02100100101234566810" + }, + { + "id": "tx-hub31-2026-05", + "settled_at": "2026-05-02T08:00:00Z", + "label": "HUB31", + "side": "debit", + "amount": 297.5, + "currency": "EUR", + "category": "other_expense", + "operation_type": "transfer", + "status": "completed", + "iban": "DE02100100101234566810" + }, + { + "id": "tx-stripe-2026-06", + "settled_at": "2026-06-29T08:00:00Z", + "label": "Stripe Technology Europe Ltd", + "side": "credit", + "amount": 8.55, + "currency": "EUR", + "category": "other_income", + "operation_type": "income", + "status": "completed", + "iban": "DE02100100101234566810" + }, + { + "id": "tx-old-window", + "settled_at": "2026-02-01T08:00:00Z", + "label": "Old Expense", + "side": "debit", + "amount": 12.34, + "currency": "EUR", + "category": "other_expense", + "operation_type": "income", + "status": "completed", + "iban": "DE02100100101234566810" + } + ] +} diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..1f99315 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,213 @@ +from pathlib import Path + +import httpx + +from qonto_assistant.audit import AuditLogger +from qonto_assistant.config import Settings +from qonto_assistant.contracts import ActorClaims +from qonto_assistant.credentials import EnvironmentCredentialProvider +from qonto_assistant.errors import PolicyDeniedError +from qonto_assistant.policy import PolicyEngine +from qonto_assistant.qonto_client import QontoClient +from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter +from qonto_assistant.service import CapabilityService + +POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml" + + +def _settings() -> Settings: + return Settings( + service_name="qonto-assistant", + default_tenant_id="binky", + default_actor_lane="green", + required_scope="finance.qonto.read", + enforce_scope=False, + policy_file=POLICY_FILE, + qonto_base_url="https://example.test", + qonto_fixture_dir=None, + qonto_auth_mode="legacy_api_key", + qonto_organization_path="/v2/organization", + qonto_transactions_path="/v2/transactions", + qonto_timeout_seconds=1, + qonto_max_retries=0, + qonto_secret_ttl_seconds=60, + rate_limit_requests=20, + rate_limit_window_seconds=60, + max_concurrency=4, + credential_source="env", + openbao_path="tenants/binky/qonto-api", + openbao_command="bao", + openbao_timeout_seconds=5, + host="127.0.0.1", + port=8080, + ) + + +def _claims() -> ActorClaims: + return ActorClaims(actor_id="codex", tenant_id="binky", lane="green") + + +def _service(monkeypatch) -> tuple[CapabilityService, list[dict[str, object]]]: + monkeypatch.setenv("API_USER", "binky-user") + monkeypatch.setenv("API_KEY", "top-secret") + events: list[dict[str, object]] = [] + settings = _settings() + + organization_payload = { + "organization": { + "name": "Binky Hedgehog GmbH", + "legal_name": "Binky Hedgehog GmbH", + "slug": "binky-hedgehog-gmbh-6923", + "legal_country": "DE", + "legal_registration_date": "2019-03-15", + "bank_accounts": [ + { + "name": "Hauptkonto", + "slug": "main-account", + "currency": "EUR", + "balance": 2185.94, + "authorized_balance": 2185.94, + "iban": "DE02100100101234566810", + "main": True, + "status": "active", + }, + { + "name": "Kickstart Business", + "slug": "secondary-account", + "currency": "EUR", + "balance": 0, + "authorized_balance": 0, + "iban": "DE02100100101234567038", + "main": False, + "status": "active", + }, + ], + } + } + transactions_payload = { + "transactions": [ + { + "id": "tx-qonto", + "settled_at": "2026-07-01T08:00:00Z", + "label": "Qonto", + "side": "debit", + "amount": 70.8, + "currency": "EUR", + "category": "subscription", + "operation_type": "qonto_fee", + "status": "completed", + }, + { + "id": "tx-hub31-1", + "settled_at": "2026-06-02T08:00:00Z", + "label": "HUB31", + "side": "debit", + "amount": 297.5, + "currency": "EUR", + "category": "other_expense", + "operation_type": "transfer", + "status": "completed", + }, + { + "id": "tx-hub31-2", + "settled_at": "2026-05-02T08:00:00Z", + "label": "HUB31", + "side": "debit", + "amount": 297.5, + "currency": "EUR", + "category": "other_expense", + "operation_type": "transfer", + "status": "completed", + }, + { + "id": "tx-stripe", + "settled_at": "2026-06-29T08:00:00Z", + "label": "Stripe", + "side": "credit", + "amount": 8.55, + "currency": "EUR", + "category": "other_income", + "operation_type": "income", + "status": "completed", + }, + ] + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v2/organization": + return httpx.Response(200, json=organization_payload) + if request.url.path == "/v2/transactions": + return httpx.Response(200, json=transactions_payload) + return httpx.Response(404, json={"error": "not_found"}) + + 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=EnvironmentCredentialProvider(), + transport=httpx.MockTransport(handler), + ) + policy = PolicyEngine.from_file( + settings.policy_file, + required_scope=settings.required_scope, + enforce_scope=settings.enforce_scope, + ) + service = CapabilityService( + client=client, + policy=policy, + audit_logger=AuditLogger(sink=events.append), + rate_limiter=RateLimiter(limit=20, window_seconds=60), + concurrency_limiter=ConcurrencyLimiter(limit=4), + ) + return service, events + + +def test_accounts_contract_returns_redacted_summary(monkeypatch) -> None: + service, _ = _service(monkeypatch) + + payload = service.get_accounts(claims=_claims(), request_id="req-accounts") + + assert payload["organization"]["name"] == "Binky Hedgehog GmbH" + assert payload["accounts"][0]["iban_last4"] == "6810" + assert "iban" not in payload["accounts"][0] + + +def test_transactions_contract_denies_oversized_page_size(monkeypatch) -> None: + service, events = _service(monkeypatch) + + try: + service.list_transactions( + claims=_claims(), + request_id="req-deny", + account_slug=None, + page=1, + page_size=101, + window_days=31, + status="completed", + side=None, + ) + except PolicyDeniedError as exc: + assert exc.error_code == "arg_constraint" + else: + raise AssertionError("Expected policy denial") + + assert events[-1]["decision"] == "deny" + assert events[-1]["deny_reason"] == "arg_constraint" + + +def test_snapshot_contract_returns_cost_run_rate_hints_for_90_day_window(monkeypatch) -> None: + service, events = _service(monkeypatch) + + payload = service.get_snapshot( + claims=_claims(), + request_id="req-snapshot", + window_days=90, + page_size=50, + ) + + assert payload["summary"]["total_balance"] == 2185.94 + assert payload["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31" + assert any(event["capability"] == "snapshot_bundle" for event in events) diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..928079a --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,25 @@ +import logging + +from qonto_assistant.audit import AuditLogger, REDACTED + + +def test_audit_logger_redacts_secret_fields() -> None: + events: list[dict[str, object]] = [] + logger = logging.getLogger("qonto_assistant.audit.test") + logger.handlers.clear() + audit = AuditLogger(logger=logger, sink=events.append) + + payload = audit.emit( + { + "authorization": "Bearer super-secret", + "api_key": "top-secret", + "nested": {"token": "child-secret"}, + "capability": "org_summary", + } + ) + + assert payload["authorization"] == REDACTED + assert payload["api_key"] == REDACTED + assert payload["nested"]["token"] == REDACTED + assert "super-secret" not in str(events[0]) + assert "top-secret" not in str(events[0]) diff --git a/tests/test_fixture_client.py b/tests/test_fixture_client.py new file mode 100644 index 0000000..c523330 --- /dev/null +++ b/tests/test_fixture_client.py @@ -0,0 +1,32 @@ +from pathlib import Path + +from qonto_assistant.qonto_client import FixtureQontoClient + +FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto" + + +def test_fixture_client_loads_fixture_payloads() -> None: + client = FixtureQontoClient(fixture_dir=FIXTURE_DIR) + + organization = client.get_organization() + + assert organization["organization"]["name"] == "Binky Hedgehog GmbH" + assert len(organization["organization"]["bank_accounts"]) == 2 + + +def test_fixture_client_filters_window_and_pagination() -> None: + client = FixtureQontoClient(fixture_dir=FIXTURE_DIR) + + payload = client.list_transactions( + iban="DE02100100101234566810", + page=1, + page_size=2, + window_days=31, + status="completed", + side=None, + ) + + transactions = payload["transactions"] + assert len(transactions) == 2 + assert transactions[0]["id"] == "tx-qonto-2026-07" + assert all(item["id"] != "tx-old-window" for item in transactions) diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 0000000..e1dc7c4 --- /dev/null +++ b/tests/test_policy.py @@ -0,0 +1,87 @@ +from pathlib import Path + +from qonto_assistant.contracts import ActorClaims, CapabilityRequest +from qonto_assistant.policy import PolicyEngine + +POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml" + + +def _policy(*, enforce_scope: bool = False) -> PolicyEngine: + return PolicyEngine.from_file( + POLICY_FILE, + required_scope="finance.qonto.read", + enforce_scope=enforce_scope, + ) + + +def _request(capability_id: str, **request_args: object) -> CapabilityRequest: + claims = ActorClaims( + actor_id="agent-1", + tenant_id="binky", + lane="green", + scopes=frozenset({"finance.qonto.read"}), + ) + return CapabilityRequest( + capability_id=capability_id, + tenant_id="binky", + actor_claims=claims, + resource_scope="test", + request_args=dict(request_args), + protocol="rest", + ) + + +def test_policy_allows_known_read_capability() -> None: + decision = _policy().decide(_request("org_summary")) + assert decision.allowed is True + assert decision.reason == "allow" + + +def test_policy_denies_cross_tenant_requests() -> None: + claims = ActorClaims(actor_id="agent-1", tenant_id="other", lane="green") + request = CapabilityRequest( + capability_id="org_summary", + tenant_id="binky", + actor_claims=claims, + resource_scope="accounts", + request_args={}, + protocol="rest", + ) + decision = _policy().decide(request) + assert decision.allowed is False + assert decision.reason == "tenant_scope" + + +def test_policy_denies_excessive_page_size() -> None: + decision = _policy().decide(_request("list_transactions", page=1, page_size=101, window_days=31)) + assert decision.allowed is False + assert decision.reason == "arg_constraint" + + +def test_policy_denies_volume_cost_shaped_requests() -> None: + decision = _policy().decide( + _request("list_transactions", page=1, page_size=50, window_days=31, operation_type="card_operation") + ) + assert decision.allowed is False + assert decision.reason == "volume_cost" + + +def test_policy_denies_credential_exfiltration_flags() -> None: + decision = _policy().decide(_request("list_transactions", page=1, page_size=50, window_days=31, include_full_iban=True)) + assert decision.allowed is False + assert decision.reason == "credential_exfil" + + +def test_policy_enforces_scope_when_enabled() -> None: + claims = ActorClaims(actor_id="agent-1", tenant_id="binky", lane="green", scopes=frozenset()) + request = CapabilityRequest( + capability_id="org_summary", + tenant_id="binky", + actor_claims=claims, + resource_scope="accounts", + request_args={}, + protocol="rest", + ) + decision = _policy(enforce_scope=True).decide(request) + assert decision.allowed is False + assert decision.reason == "authz_denied" diff --git a/tests/test_qonto_client.py b/tests/test_qonto_client.py new file mode 100644 index 0000000..3c91070 --- /dev/null +++ b/tests/test_qonto_client.py @@ -0,0 +1,84 @@ +import httpx + +from qonto_assistant.contracts import QontoCredentials +from qonto_assistant.qonto_client import QontoClient + + +class StubCredentialProvider: + def __init__(self) -> None: + self.invalidated = False + + def get_credentials(self) -> QontoCredentials: + return QontoCredentials(api_user="binky-user", api_key="top-secret") + + def invalidate(self) -> None: + self.invalidated = True + + +def test_client_sends_legacy_api_key_header_and_params() -> None: + provider = StubCredentialProvider() + seen: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["auth"] = request.headers["Authorization"] + seen["path"] = request.url.path + seen["params"] = dict(request.url.params) + return httpx.Response(200, json={"transactions": []}) + + client = QontoClient( + base_url="https://example.test", + organization_path="/v2/organization", + transactions_path="/v2/transactions", + auth_mode="legacy_api_key", + timeout_seconds=1, + max_retries=0, + credential_provider=provider, + transport=httpx.MockTransport(handler), + ) + + client.list_transactions( + iban="DE1234567890", + page=2, + page_size=25, + window_days=14, + status="completed", + side="debit", + ) + + assert seen["auth"] == "binky-user:top-secret" + assert seen["path"] == "/v2/transactions" + assert seen["params"] == { + "iban": "DE1234567890", + "current_page": "2", + "per_page": "25", + "window_days": "14", + "status": "completed", + "side": "debit", + } + + +def test_client_invalidates_cached_credentials_on_auth_failure() -> None: + provider = StubCredentialProvider() + + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"error": "unauthorized"}) + + client = QontoClient( + base_url="https://example.test", + organization_path="/v2/organization", + transactions_path="/v2/transactions", + auth_mode="legacy_api_key", + timeout_seconds=1, + max_retries=0, + credential_provider=provider, + transport=httpx.MockTransport(handler), + ) + + try: + client.get_organization() + except Exception as exc: # noqa: BLE001 + assert getattr(exc, "error_code", None) == "qonto_auth_failed" + else: + raise AssertionError("Expected Qonto auth failure") + + assert provider.invalidated is True diff --git a/tests/test_snapshot_semantics.py b/tests/test_snapshot_semantics.py new file mode 100644 index 0000000..063bdb3 --- /dev/null +++ b/tests/test_snapshot_semantics.py @@ -0,0 +1,53 @@ +from pathlib import Path + +from qonto_assistant.audit import AuditLogger +from qonto_assistant.contracts import ActorClaims +from qonto_assistant.policy import PolicyEngine +from qonto_assistant.qonto_client import FixtureQontoClient +from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter +from qonto_assistant.service import CapabilityService + +POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml" +FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto" + + +def _claims() -> ActorClaims: + return ActorClaims(actor_id="codex", tenant_id="binky", lane="green") + + +def _service() -> CapabilityService: + return CapabilityService( + client=FixtureQontoClient(fixture_dir=FIXTURE_DIR), + policy=PolicyEngine.from_file( + POLICY_FILE, + required_scope="finance.qonto.read", + enforce_scope=False, + ), + audit_logger=AuditLogger(sink=lambda _: None), + rate_limiter=RateLimiter(limit=20, window_seconds=60), + concurrency_limiter=ConcurrencyLimiter(limit=4), + ) + + +def test_snapshot_recent_window_excludes_recurring_hint() -> None: + payload = _service().get_snapshot( + claims=_claims(), + request_id="req-snapshot-31", + window_days=31, + page_size=50, + ) + + assert payload["cost_run_rate_hints"]["recurring_debits"] == [] + + +def test_snapshot_90_day_window_detects_recurring_hint() -> None: + payload = _service().get_snapshot( + claims=_claims(), + request_id="req-snapshot-90", + window_days=90, + page_size=50, + ) + + recurring = payload["cost_run_rate_hints"]["recurring_debits"] + assert recurring[0]["label"] == "HUB31" + assert recurring[0]["occurrences"] == 2 diff --git a/workplans/ADHOC-2026-07-21.md b/workplans/ADHOC-2026-07-21.md new file mode 100644 index 0000000..fd59653 --- /dev/null +++ b/workplans/ADHOC-2026-07-21.md @@ -0,0 +1,51 @@ +--- +id: ADHOC-2026-07-21 +type: workplan +title: "Post-Phase-1 cleanup and local smoke support" +domain: infotech +repo: qonto-assistant +status: finished +owner: codex +topic_slug: the-custodian +created: "2026-07-21" +updated: "2026-07-21" +state_hub_workstream_id: "65e254a7-4feb-4179-a4fe-4d5ede8ce963" +--- + +# Post-Phase-1 cleanup and local smoke support + +Low-risk follow-up after QONTO-WP-0002 completion: + +- add fixture-backed local Qonto source so the service can run without real bank credentials +- add a real HTTP smoke script around the Phase 1 REST surface +- add repo classification metadata so `fix-consistency` no longer warns about the missing file + +## Task: Fixture-backed local smoke mode + +```task +id: ADHOC-2026-07-21-T01 +status: done +priority: medium +state_hub_task_id: "a6ee7d5c-8bfc-46ba-a951-32e8bbc41c96" +``` + +Add a local fixture-backed Qonto client path and a smoke script that starts the +service against canned organization/transaction payloads, then verifies +`/v1/health`, `/v1/accounts`, and `/v1/snapshot`. + +Done when: the service can be run locally without real Qonto credentials and a +documented smoke path exists. + +## Task: Repo classification metadata + +```task +id: ADHOC-2026-07-21-T02 +status: done +priority: low +state_hub_task_id: "6f1289bc-104e-4812-a50c-c8b5145df2b9" +``` + +Add `.repo-classification.yaml` for `qonto-assistant` so State Hub consistency +checks no longer warn about the missing classification file. + +Done when: `fix-consistency` reports no classification-gap warning for this repo. diff --git a/workplans/QONTO-WP-0001-statehub-bootstrap.md b/workplans/QONTO-WP-0001-statehub-bootstrap.md index 20ff32b..0387add 100644 --- a/workplans/QONTO-WP-0001-statehub-bootstrap.md +++ b/workplans/QONTO-WP-0001-statehub-bootstrap.md @@ -36,7 +36,7 @@ refined; README points at specs/research; register generated AGENTS.md + brief. ```task id: QONTO-WP-0001-T02 -status: wait +status: done priority: medium state_hub_task_id: "360f399a-36f8-44e6-af8a-55fb0d5732a3" ``` @@ -45,7 +45,10 @@ Identify the repo's install, test, lint, build, and run commands. Add or refine those commands in the agent instructions so future coding sessions can verify changes confidently. -**Blocked on** runtime stack choice in QONTO-WP-0002-T01. Revisit after that task. +**Done 2026-07-21:** Runtime stack and commands are now documented. Local +verification prefers `make install-dev`, `make test`, `make lint`, and +`make run`; fallback verification for this workstation is documented in +`AGENTS.md` and `docs/operator-runbook.md`. ## Seed First Real Workplan diff --git a/workplans/QONTO-WP-0002-policy-kernel-and-rest.md b/workplans/QONTO-WP-0002-policy-kernel-and-rest.md index 61b60e5..564ba47 100644 --- a/workplans/QONTO-WP-0002-policy-kernel-and-rest.md +++ b/workplans/QONTO-WP-0002-policy-kernel-and-rest.md @@ -4,7 +4,7 @@ type: workplan title: "Phase 1 — policy kernel and read-only REST" domain: infotech repo: qonto-assistant -status: active +status: finished owner: codex topic_slug: the-custodian created: "2026-07-21" @@ -29,7 +29,7 @@ CCR-2026-0008) — already provisioned. ```task id: QONTO-WP-0002-T01 -status: progress +status: done priority: high state_hub_task_id: "f9e129f3-5bd4-43e1-b7a0-281e4d3dec2a" ``` @@ -47,7 +47,7 @@ policy, qonto client, api, audit). ```task id: QONTO-WP-0002-T02 -status: progress +status: done priority: high state_hub_task_id: "552ff651-dc66-4e65-97fe-3ec26652bbdd" ``` @@ -69,7 +69,7 @@ Done when: policy tests pass in CI/local; no network required. ```task id: QONTO-WP-0002-T03 -status: progress +status: done priority: high state_hub_task_id: "be3aa7b6-f28c-4436-bd5d-d6940de6c2ce" ``` @@ -92,7 +92,7 @@ Done when: unit tests with mocked HTTP; optional live smoke behind a flag. ```task id: QONTO-WP-0002-T04 -status: progress +status: done priority: high state_hub_task_id: "678b0b26-15af-4037-849f-d24d320588ac" ``` @@ -114,7 +114,7 @@ mapping is explicit and shared with future MCP. ```task id: QONTO-WP-0002-T05 -status: progress +status: done priority: medium state_hub_task_id: "4a42dff1-1281-4cc1-ba6a-24702bce7dc9" ``` @@ -131,7 +131,7 @@ Done when: tests assert secrets absent from log lines for a sample allow/deny. ```task id: QONTO-WP-0002-T06 -status: progress +status: done priority: medium state_hub_task_id: "7b7ca0f7-f523-473e-b3f6-fe0564f54ed5" ``` @@ -147,10 +147,14 @@ pasting keys into chat. ```task id: QONTO-WP-0002-T07 -status: todo +status: done priority: low state_hub_task_id: "9b99ba05-99f2-4624-9044-89bf37055434" ``` -Mark workplan finished when T01–T06 done; note Phase 2 seed (MCP surface for -all harnesses) in closure. Run `statehub fix-consistency`. +**Done 2026-07-21:** Phase 1 landed in `src/qonto_assistant/` with a +FastAPI-based REST service, policy YAML, Qonto client, audit layer, rate +limits, concurrency bounds, and operator runbook. Verified with +`PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest` (`12 passed`) plus +`python3 -m compileall src tests`. Phase 2 seed remains the MCP surface on the +same capability core and policy engine. Run `statehub fix-consistency`.