feat(audit): publish sequenced heartbeat and reconciliation evidence

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ec5-7e2b-7743-ac08-719e1b0f42e2
This commit is contained in:
tegwick 2026-09-05 01:39:48 +02:00
parent fce60099c4
commit b9349782f4
32 changed files with 839 additions and 111 deletions

View file

@ -1,8 +1,9 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from contextlib import AsyncExitStack, asynccontextmanager, suppress
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager, suppress
from uuid import uuid4
import uvicorn
@ -39,13 +40,18 @@ def create_app(
key_cape_verifier: KeyCapeTokenVerifier | 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,
)
if service is not None:
if audit_logger is not None and audit_logger is not service.audit_logger:
raise ValueError("service and app must share one audit_logger")
audit_logger = service.audit_logger
else:
audit_logger = audit_logger or AuditLogger()
service = _build_service(
settings=settings,
audit_logger=audit_logger,
rate_limiter=rate_limiter,
concurrency_limiter=concurrency_limiter,
)
if key_cape_verifier is None and settings.key_cape_jwks_url:
key_cape_verifier = KeyCapeTokenVerifier(
jwks_url=settings.key_cape_jwks_url,
@ -57,7 +63,9 @@ def create_app(
cache_seconds=settings.key_cape_cache_seconds,
)
mcp_server = create_mcp_server(settings=settings, service=service, key_cape_verifier=key_cape_verifier)
mcp_server = create_mcp_server(
settings=settings, service=service, key_cape_verifier=key_cape_verifier
)
mcp_app = mcp_server.streamable_http_app()
if settings.mcp_auth_token:
mcp_app.add_middleware(BearerTokenAuthMiddleware, token=settings.mcp_auth_token)
@ -66,9 +74,24 @@ def create_app(
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
yield
with suppress(Exception):
service.client.close()
audit_logger.emit_heartbeat(reason="startup")
heartbeat_task = asyncio.create_task(
_emit_audit_heartbeats(
audit_logger=audit_logger,
interval_seconds=settings.audit_heartbeat_interval_seconds,
)
)
try:
yield
finally:
try:
heartbeat_task.cancel()
with suppress(asyncio.CancelledError):
await heartbeat_task
audit_logger.emit_heartbeat(reason="shutdown")
finally:
with suppress(Exception):
service.client.close()
app = FastAPI(title="qonto-assistant", version=__version__, lifespan=lifespan)
app.state.settings = settings
@ -91,11 +114,21 @@ def create_app(
"policy_file": str(settings.policy_file),
}
@app.get("/v1/audit/reconciliation")
async def audit_reconciliation(request: Request) -> dict[str, object]:
# Apply the same deployment identity boundary as finance calls. The
# view contains counts only and deliberately does not create another
# transition in the stream it is describing.
actor_claims_from_request(request, settings, key_cape_verifier=key_cape_verifier)
return audit_logger.reconciliation_snapshot()
@app.get("/v1/accounts")
async def get_accounts(request: Request) -> JSONResponse:
claims = actor_claims_from_request(request, settings, key_cape_verifier=key_cape_verifier)
request_id = _request_id(request)
payload = await run_in_threadpool(service.get_accounts, claims=claims, request_id=request_id)
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")
@ -143,6 +176,12 @@ def create_app(
return app
async def _emit_audit_heartbeats(*, audit_logger: AuditLogger, interval_seconds: int) -> None:
while True:
await asyncio.sleep(interval_seconds)
audit_logger.emit_heartbeat(reason="periodic")
def _request_id(request: Request) -> str:
existing = getattr(request.state, "request_id", None)
if existing:

View file

@ -3,9 +3,12 @@ from __future__ import annotations
import json
import logging
from collections.abc import Callable, Mapping
from copy import deepcopy
from dataclasses import asdict, is_dataclass
from datetime import UTC, datetime
from threading import RLock
from typing import Any
from uuid import uuid4
from qonto_assistant.contracts import AuditEvent
@ -18,6 +21,8 @@ SECRET_KEYS = {
"secret",
"token",
}
AUDIT_STREAM_ID = "qonto-assistant.audit"
AUDIT_EVENT_CLASSES = ("audit.allow", "audit.deny")
def utc_now_iso() -> str:
@ -40,19 +45,107 @@ def _sanitize(value: Any) -> Any:
class AuditLogger:
"""Emit sequenced audit records and source-side reconciliation evidence.
Counters represent source transitions, not observer delivery receipts. An
observer compares these counters with received ``audit.allow`` and
``audit.deny`` records and also checks ``stream_sequence`` for gaps.
"""
def __init__(
self,
*,
logger: logging.Logger | None = None,
sink: Callable[[dict[str, Any]], None] | None = None,
instance_id: str | None = None,
clock: Callable[[], str] = utc_now_iso,
) -> None:
self.logger = logger or logging.getLogger("qonto_assistant.audit")
self.logger.setLevel(logging.INFO)
self.sink = sink
self.instance_id = instance_id or str(uuid4())
self.clock = clock
self.started_at = clock()
self._lock = RLock()
self._sequence = 0
self._transition_counts = {event_class: 0 for event_class in AUDIT_EVENT_CLASSES}
self._heartbeat_counts = dict(self._transition_counts)
self._heartbeat_at = self.started_at
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)
decision = payload.get("decision")
event_class = f"audit.{decision}" if decision in {"allow", "deny"} else "audit.event"
with self._lock:
self._sequence += 1
if event_class in self._transition_counts:
self._transition_counts[event_class] += 1
payload.update(self._stream_fields(event_class=event_class, sequence=self._sequence))
self._publish(payload)
return deepcopy(payload)
def emit_heartbeat(self, *, reason: str = "periodic") -> dict[str, Any]:
"""Emit a positive liveness claim plus counts for observer reconciliation."""
with self._lock:
now = self.clock()
self._sequence += 1
window_counts = {
event_class: self._transition_counts[event_class]
- self._heartbeat_counts[event_class]
for event_class in AUDIT_EVENT_CLASSES
}
payload: dict[str, Any] = {
**self._stream_fields(event_class="audit.heartbeat", sequence=self._sequence),
"timestamp": now,
"reason": reason,
"assertion": (
"nothing-to-report"
if window_counts["audit.deny"] == 0
else "transitions-reported"
),
"window_started_at": self._heartbeat_at,
"window_ended_at": now,
"source_transition_counts": dict(self._transition_counts),
"window_transition_counts": window_counts,
}
self._heartbeat_counts = dict(self._transition_counts)
self._heartbeat_at = now
self._publish(payload)
return deepcopy(payload)
def reconciliation_snapshot(self) -> dict[str, Any]:
"""Return non-secret source state without adding an audit-stream record."""
with self._lock:
now = self.clock()
return {
"stream_id": AUDIT_STREAM_ID,
"stream_instance_id": self.instance_id,
"instance_started_at": self.started_at,
"snapshot_at": now,
"last_stream_sequence": self._sequence,
"last_heartbeat_at": self._heartbeat_at,
"source_transition_counts": dict(self._transition_counts),
"since_last_heartbeat_counts": {
event_class: self._transition_counts[event_class]
- self._heartbeat_counts[event_class]
for event_class in AUDIT_EVENT_CLASSES
},
}
def _stream_fields(self, *, event_class: str, sequence: int) -> dict[str, Any]:
return {
"event_class": event_class,
"stream_id": AUDIT_STREAM_ID,
"stream_instance_id": self.instance_id,
"stream_sequence": sequence,
}
def _publish(self, payload: dict[str, Any]) -> None:
# Structured logging is the production hot-path sink. Publish it first
# so an optional secondary sink cannot suppress the primary record.
self.logger.info(json.dumps(payload, sort_keys=True))
return payload
if self.sink is not None:
self.sink(deepcopy(payload))

View file

@ -32,6 +32,7 @@ class Settings:
deny_escalation_threshold: int
deny_escalation_window_seconds: int
deny_escalation_lockout_seconds: int
audit_heartbeat_interval_seconds: int
key_cape_jwks_url: str | None
key_cape_issuer: str
key_cape_audience: str
@ -81,10 +82,18 @@ class Settings:
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")),
deny_escalation_enabled=os.getenv("QONTO_DENY_ESCALATION_ENABLED", "true").lower() == "true",
deny_escalation_enabled=os.getenv("QONTO_DENY_ESCALATION_ENABLED", "true").lower()
== "true",
deny_escalation_threshold=int(os.getenv("QONTO_DENY_ESCALATION_THRESHOLD", "3")),
deny_escalation_window_seconds=int(os.getenv("QONTO_DENY_ESCALATION_WINDOW_SECONDS", "60")),
deny_escalation_lockout_seconds=int(os.getenv("QONTO_DENY_ESCALATION_LOCKOUT_SECONDS", "300")),
deny_escalation_window_seconds=int(
os.getenv("QONTO_DENY_ESCALATION_WINDOW_SECONDS", "60")
),
deny_escalation_lockout_seconds=int(
os.getenv("QONTO_DENY_ESCALATION_LOCKOUT_SECONDS", "300")
),
audit_heartbeat_interval_seconds=max(
1, int(os.getenv("QONTO_AUDIT_HEARTBEAT_INTERVAL_SECONDS", "86400"))
),
key_cape_jwks_url=os.getenv("QONTO_KEY_CAPE_JWKS_URL") or None,
key_cape_issuer=os.getenv("QONTO_KEY_CAPE_ISSUER", "https://key-cape.netkingdom"),
key_cape_audience=os.getenv("QONTO_KEY_CAPE_AUDIENCE", "qonto-assistant"),
@ -94,7 +103,9 @@ class Settings:
flex_auth_base_url=os.getenv("QONTO_FLEX_AUTH_URL") or None,
flex_auth_timeout_seconds=float(os.getenv("QONTO_FLEX_AUTH_TIMEOUT_SECONDS", "3")),
tenant_engine_base_url=os.getenv("QONTO_TENANT_ENGINE_URL") or None,
tenant_engine_timeout_seconds=float(os.getenv("QONTO_TENANT_ENGINE_TIMEOUT_SECONDS", "3")),
tenant_engine_timeout_seconds=float(
os.getenv("QONTO_TENANT_ENGINE_TIMEOUT_SECONDS", "3")
),
tenant_engine_required_roles=frozenset(
role.strip()
for role in os.getenv("QONTO_TENANT_ENGINE_REQUIRED_ROLES", "VEN,CUS").split(",")

View file

@ -12,6 +12,7 @@ class ActorClaims:
tenant_id: str
lane: str = "green"
scopes: frozenset[str] = field(default_factory=frozenset)
identity_binding: str = "self_asserted"
@dataclass(frozen=True, slots=True)
@ -52,5 +53,7 @@ class AuditEvent:
deny_reason: str | None
policy_version: int
latency_ms: int
identity_binding: str
egress_destination: str
qonto_http_status: int | None = None
result_count: int | None = None

View file

@ -74,7 +74,9 @@ class OpenBaoCliCredentialProvider:
return value
def build_credential_provider(settings: Settings) -> EnvironmentCredentialProvider | OpenBaoCliCredentialProvider:
def build_credential_provider(
settings: Settings,
) -> EnvironmentCredentialProvider | OpenBaoCliCredentialProvider:
if settings.credential_source == "bao-cli":
return OpenBaoCliCredentialProvider(
command=settings.openbao_command,

View file

@ -102,6 +102,7 @@ class KeyCapeTokenVerifier:
tenant_id=str(claims["tenant"]),
lane=_lane_from_roles(claims.get("roles"), default=self.default_lane),
scopes=frozenset(_coerce_scopes(claims.get("scope") or claims.get("scp"))),
identity_binding="key_cape_jwt",
)
def _ensure_keys(self) -> None:

View file

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

View file

@ -86,7 +86,7 @@ def create_mcp_server(
window_days: int = 90,
page_size: int = 50,
) -> dict[str, Any]:
"""Normalized recurring-cost hints, not a raw export (cost_run_rate_hints capability)."""
"""Normalized recurring-cost hints for the cost_run_rate_hints capability."""
claims = _claims(ctx, settings, key_cape_verifier)
return service.get_cost_run_rate_hints(
claims=claims,
@ -103,7 +103,9 @@ def mcp_asgi_app(*, settings: Settings, service: CapabilityService | None = None
return create_mcp_server(settings=settings, service=service).streamable_http_app()
def _claims(ctx: Context, settings: Settings, key_cape_verifier: KeyCapeTokenVerifier | None = None):
def _claims(
ctx: Context, settings: Settings, key_cape_verifier: KeyCapeTokenVerifier | None = None
):
headers = _headers_from_context(ctx)
return actor_claims_from_headers(headers, settings, key_cape_verifier=key_cape_verifier)

View file

@ -10,7 +10,9 @@ from qonto_assistant.contracts import CapabilityRequest, PolicyDecision
class PolicyEngine:
def __init__(self, config: Mapping[str, Any], *, required_scope: str, enforce_scope: bool) -> None:
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
@ -54,7 +56,9 @@ class PolicyEngine:
request_args=dict(request.request_args),
)
def _constraints_ok(self, capability: Mapping[str, Any], request_args: Mapping[str, Any]) -> bool:
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),
@ -78,9 +82,14 @@ class PolicyEngine:
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", []))
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)}
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):
@ -100,11 +109,15 @@ class PolicyEngine:
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"):
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"):
if any(key in request.request_args for key in amount_keys) and request.request_args.get(
"execute"
):
return "spend"
return None

View file

@ -4,8 +4,7 @@ import json
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Protocol
from typing import Any, Protocol
import httpx
@ -101,7 +100,9 @@ class QontoClient:
if attempt < self.max_retries:
attempt += 1
continue
raise UpstreamError("Qonto request timed out", error_code="qonto_timeout", status_code=504) from exc
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
@ -179,7 +180,9 @@ class FixtureQontoClient:
status_code=500,
)
filtered: list[Mapping[str, Any]] = [item for item in transactions if isinstance(item, Mapping)]
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:
@ -221,14 +224,18 @@ class FixtureQontoClient:
)
return payload
def _filter_window(self, transactions: list[Mapping[str, Any]], window_days: int) -> list[Mapping[str, Any]]:
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))
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:

View file

@ -7,7 +7,13 @@ 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, PolicyDecision, ProtocolName
from qonto_assistant.contracts import (
ActorClaims,
AuditEvent,
CapabilityRequest,
PolicyDecision,
ProtocolName,
)
from qonto_assistant.errors import InvalidRequestError, PolicyDeniedError, UpstreamError
from qonto_assistant.live_authorization import LiveAuthorizationGate
from qonto_assistant.policy import PolicyEngine
@ -102,7 +108,9 @@ class CapabilityService:
resource_scope="snapshot",
request_id=request_id,
protocol=protocol,
operation=lambda _: self._build_snapshot_payload(window_days=window_days, page_size=page_size),
operation=lambda _: self._build_snapshot_payload(
window_days=window_days, page_size=page_size
),
)
def get_cost_run_rate_hints(
@ -227,13 +235,17 @@ class CapabilityService:
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)]
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),
"authorized_balance": round(
sum(account["authorized_balance"] for account in accounts), 2
),
},
}
@ -259,7 +271,9 @@ class CapabilityService:
status=status,
side=side,
)
transactions = [_normalize_transaction(item) for item in _extract_transactions(raw_transactions)]
transactions = [
_normalize_transaction(item) for item in _extract_transactions(raw_transactions)
]
return {
"organization": _normalize_organization(organization),
"account": _normalize_account(selected_account),
@ -272,7 +286,9 @@ class CapabilityService:
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)
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(
@ -297,10 +313,14 @@ class CapabilityService:
"recent_transactions": recent_transactions[:10],
}
def _build_cost_run_rate_hints_payload(self, *, window_days: int, page_size: int) -> dict[str, Any]:
def _build_cost_run_rate_hints_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)
main_account = next(
(account for account in accounts if account["main"]), accounts[0] if accounts else None
)
recent_transactions: list[dict[str, Any]] = []
if main_account is not None:
transactions_payload = self._build_transactions_payload(
@ -343,6 +363,8 @@ class CapabilityService:
deny_reason=deny_reason,
policy_version=self.policy.version,
latency_ms=latency_ms,
identity_binding=claims.identity_binding,
egress_destination="qonto-thirdparty-api",
qonto_http_status=qonto_http_status,
result_count=result_count,
)
@ -429,7 +451,9 @@ def _amount_value(raw: Mapping[str, Any], key: str = "balance") -> float:
return round(float(amount), 2)
def _select_account(accounts: list[Mapping[str, Any]], account_slug: str | None) -> Mapping[str, Any]:
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",
@ -440,7 +464,9 @@ def _select_account(accounts: list[Mapping[str, Any]], account_slug: str | None)
for account in accounts:
if account.get("slug") == account_slug:
return account
raise InvalidRequestError(f"Unknown account slug: {account_slug}", error_code="resource_scope")
raise InvalidRequestError(
f"Unknown account slug: {account_slug}", error_code="resource_scope"
)
for account in accounts:
if account.get("main"):
return account
@ -485,11 +511,19 @@ def _build_cost_run_rate_hints(transactions: list[dict[str, Any]]) -> dict[str,
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"),
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"),
sum(
float(transaction["amount"])
for transaction in transactions
if transaction.get("side") == "credit"
),
2,
)
return {