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
26
.repo-classification.yaml
Normal file
26
.repo-classification.yaml
Normal file
|
|
@ -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.
|
||||
26
AGENTS.md
26
AGENTS.md
|
|
@ -126,18 +126,28 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
|||
`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`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
33
Makefile
Normal file
33
Makefile
Normal file
|
|
@ -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
|
||||
27
README.md
27
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
183
docs/operator-runbook.md
Normal file
183
docs/operator-runbook.md
Normal file
|
|
@ -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.
|
||||
48
pyproject.toml
Normal file
48
pyproject.toml
Normal file
|
|
@ -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"]
|
||||
127
scripts/smoke_rest_api.py
Normal file
127
scripts/smoke_rest_api.py
Normal file
|
|
@ -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())
|
||||
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
|
||||
31
tests/fixtures/qonto/organization.json
vendored
Normal file
31
tests/fixtures/qonto/organization.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
64
tests/fixtures/qonto/transactions.json
vendored
Normal file
64
tests/fixtures/qonto/transactions.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
213
tests/test_api.py
Normal file
213
tests/test_api.py
Normal file
|
|
@ -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)
|
||||
25
tests/test_audit.py
Normal file
25
tests/test_audit.py
Normal file
|
|
@ -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])
|
||||
32
tests/test_fixture_client.py
Normal file
32
tests/test_fixture_client.py
Normal file
|
|
@ -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)
|
||||
87
tests/test_policy.py
Normal file
87
tests/test_policy.py
Normal file
|
|
@ -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"
|
||||
84
tests/test_qonto_client.py
Normal file
84
tests/test_qonto_client.py
Normal file
|
|
@ -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
|
||||
53
tests/test_snapshot_semantics.py
Normal file
53
tests/test_snapshot_semantics.py
Normal file
|
|
@ -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
|
||||
51
workplans/ADHOC-2026-07-21.md
Normal file
51
workplans/ADHOC-2026-07-21.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue