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: