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:
parent
fce60099c4
commit
b9349782f4
32 changed files with 839 additions and 111 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue