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
2
SCOPE.md
2
SCOPE.md
|
|
@ -43,7 +43,7 @@ client.
|
|||
| INTENT / SCOPE / AGENTS | present |
|
||||
| Architecture blueprint | `specs/ArchitectureBlueprint.md` |
|
||||
| Research notes | `research/2026-07-21-…` |
|
||||
| Runtime implementation | not started (see workplans) |
|
||||
| Runtime implementation | REST + MCP, policy/auth gates, audit reconciliation implemented (see workplans) |
|
||||
| Upstream custody | live in platform (`binky-qonto-api`, CCR-2026-0008) |
|
||||
|
||||
## Getting oriented
|
||||
|
|
|
|||
|
|
@ -8,13 +8,16 @@
|
|||
|
||||
| Kind | ID | Status | Lane | Source |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| workplan | ADHOC-2026-07-21 | finished | — | workplans/ADHOC-2026-07-21.md |
|
||||
| workplan | QONTO-WP-ADHOC-2026-07-21 | finished | — | workplans/ADHOC-2026-07-21.md |
|
||||
| workplan | ADHOC-2026-09-05 | finished | — | workplans/ADHOC-2026-09-05.md |
|
||||
| workplan | QONTO-WP-0001 | finished | — | workplans/QONTO-WP-0001-statehub-bootstrap.md |
|
||||
| workplan | QONTO-WP-0002 | finished | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
|
||||
| workplan | QONTO-WP-0003 | finished | — | workplans/QONTO-WP-0003-mcp-surface.md |
|
||||
| workplan | QONTO-WP-0004 | finished | — | workplans/QONTO-WP-0004-security-hardening-and-scale-to-zero.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 |
|
||||
| workplan | QONTO-WP-0005 | finished | — | workplans/QONTO-WP-0005-audit-deny-stream-completeness.md |
|
||||
| task | QONTO-WP-ADHOC-2026-07-21-T01 | done | — | workplans/ADHOC-2026-07-21.md |
|
||||
| task | QONTO-WP-ADHOC-2026-07-21-T02 | done | — | workplans/ADHOC-2026-07-21.md |
|
||||
| task | ADHOC-2026-09-05-T01 | done | — | workplans/ADHOC-2026-09-05.md |
|
||||
| task | QONTO-WP-0001-T01 | done | — | 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 |
|
||||
|
|
@ -40,3 +43,6 @@
|
|||
| task | QONTO-WP-0004-T06 | done | — | workplans/QONTO-WP-0004-security-hardening-and-scale-to-zero.md |
|
||||
| task | QONTO-WP-0004-T07 | done | — | workplans/QONTO-WP-0004-security-hardening-and-scale-to-zero.md |
|
||||
| task | QONTO-WP-0004-T08 | done | — | workplans/QONTO-WP-0004-security-hardening-and-scale-to-zero.md |
|
||||
| task | QONTO-WP-0005-T01 | done | — | workplans/QONTO-WP-0005-audit-deny-stream-completeness.md |
|
||||
| task | QONTO-WP-0005-T02 | done | — | workplans/QONTO-WP-0005-audit-deny-stream-completeness.md |
|
||||
| task | QONTO-WP-0005-T03 | done | — | workplans/QONTO-WP-0005-audit-deny-stream-completeness.md |
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ data:
|
|||
QONTO_RATE_LIMIT_WINDOW_SECONDS: "60"
|
||||
QONTO_MAX_CONCURRENCY: "4"
|
||||
QONTO_DENY_ESCALATION_ENABLED: "true"
|
||||
QONTO_AUDIT_HEARTBEAT_INTERVAL_SECONDS: "86400"
|
||||
# QONTO_KEY_CAPE_JWKS_URL: TODO once key-cape has a confirmed in-cluster address
|
||||
# QONTO_FLEX_AUTH_URL: TODO once flex-auth has a confirmed in-cluster address
|
||||
# QONTO_TENANT_ENGINE_URL: TODO once tenant-engine is deployed anywhere reachable
|
||||
|
|
|
|||
|
|
@ -195,11 +195,11 @@ effect of how it was built, not extra work:
|
|||
recovery procedure with no data-loss risk (`maximum_data_loss: PT0S`
|
||||
applies trivially since there is no persisted data).
|
||||
|
||||
## 9. Kings Guard mapping (prep now, cheap; enforcement later)
|
||||
## 9. King's Guard observation mapping
|
||||
|
||||
Nothing below requires `kings-guard` to exist yet. It is preparation so
|
||||
`qonto-assistant` needs zero rework once a sentinel-mesh or decision plane
|
||||
does exist.
|
||||
King's Guard has observed the structured allow/deny stream. The source keeps
|
||||
ownership of its genome and emission claims; the observer independently checks
|
||||
the records and completeness evidence it receives.
|
||||
|
||||
### 9.1 Security Genome record
|
||||
|
||||
|
|
@ -210,21 +210,29 @@ nothing else), data classification, and recovery expectations.
|
|||
|
||||
### 9.2 Audit stream is already observation-shaped
|
||||
|
||||
`AuditLogger`'s existing event shape (actor, capability, decision,
|
||||
deny_reason, latency, upstream HTTP status, policy version — see
|
||||
`tests/test_audit.py`, `tests/test_audit_parity.py`) already matches Kings
|
||||
Guard's Immune Observation contract closely enough that no schema rework
|
||||
should be needed later — just a new consumer pointed at the same stream.
|
||||
`AuditLogger`'s event shape includes actor, capability, decision, deny reason,
|
||||
latency, upstream HTTP status, policy version, identity binding, and egress
|
||||
destination (see `tests/test_audit.py`, `tests/test_audit_parity.py`). Every
|
||||
record also carries a process-instance id and monotonic stream sequence.
|
||||
|
||||
The source-owned declaration in `specs/audit-emission-cadence.yaml` classifies
|
||||
`audit.deny` as load-bearing and low-volume. A startup heartbeat, periodic
|
||||
heartbeat (24 hours by default), and best-effort shutdown heartbeat publish
|
||||
cumulative and window transition counts. Consumers compare those counts and
|
||||
sequence continuity with observed records. `/v1/audit/reconciliation` exposes
|
||||
the same non-secret current source counts for an authenticated diagnostic read.
|
||||
|
||||
### 9.3 A concrete, actionable-today signal
|
||||
|
||||
This does not require any Kings Guard component: repeated
|
||||
`arg_constraint` or `credential_exfil` deny reasons from the same actor
|
||||
within a short window is a real, current signal. It should trip a
|
||||
tightened rate limit or a temporary lockout for that actor now, using the
|
||||
audit stream and rate limiter that already exist — this is a Fast Local
|
||||
Loop (`NetKingdomImmuneArchitecture.md` §14.1) response that does not need
|
||||
to wait for any future component.
|
||||
tightened rate limit or a temporary lockout for that actor now. The current
|
||||
`DenyEscalationTracker` branches directly on the same decision path that emits
|
||||
the audit event; it does not consume the audit stream. This is a Fast Local Loop
|
||||
(`NetKingdomImmuneArchitecture.md` §14.1) response that does not need to wait
|
||||
for any future component, while the stream remains load-bearing for estate
|
||||
observation rather than for the local lockout.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -235,12 +243,12 @@ to wait for any future component.
|
|||
| Security Genome record | `qonto-assistant` | can ship now |
|
||||
| Audit-stream shape review against Immune Observation contract | `qonto-assistant` | can ship now |
|
||||
| Actor lockout on repeated deny signals | `qonto-assistant` | can ship now |
|
||||
| `key-cape` token verification in place of bearer token | `qonto-assistant` + `key-cape` | needs `key-cape` client integration support |
|
||||
| `finance.qonto.read` resource + live decision call | `qonto-assistant` + `flex-auth` | needs the resource registered in `flex-auth` |
|
||||
| Live tenant-role gate | `qonto-assistant` + `tenant-engine` | needs a `tenant-engine` lookup/cache API call wired in |
|
||||
| `key-cape` token verification in place of bearer token | `qonto-assistant` + `key-cape` | verifier shipped; live enforcement is runtime-configured |
|
||||
| `finance.qonto.read` resource + live decision call | `qonto-assistant` + `flex-auth` | client and policy shipped; live use is runtime-configured |
|
||||
| Live tenant-role gate | `qonto-assistant` + `tenant-engine` | client shipped; live use requires a reachable deployment URL |
|
||||
| Facade / scale-to-zero activator | new component (home TBD — Railiance or a dedicated repo) | design only so far |
|
||||
| I1/I2 isolation placement on `railiance01` | Railiance | needs a placement decision |
|
||||
| Sentinel-mesh / decision-plane consumption of the audit stream | `kings-guard` | does not exist yet — see intake |
|
||||
| Audit-stream observation and completeness acceptance | `kings-guard` | consuming events; cadence/reconciliation acceptance pending |
|
||||
|
||||
Tracked as `QONTO-WP-0004` in this repo, with the `kings-guard`-owned
|
||||
portion tracked as an intake against `KG-WP-0002` (pilot-lane selection).
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ Phase 1 ships:
|
|||
- policy-gated `GET /v1/transactions`
|
||||
- policy-gated `GET /v1/snapshot`
|
||||
- structured audit events without secrets
|
||||
- sequenced audit heartbeat and reconciliation evidence
|
||||
- env-backed or OpenBao-CLI-backed credential loading
|
||||
|
||||
Spend, transfer, card, invoicing, payment-link, and other volume-cost actions
|
||||
|
|
@ -207,6 +208,32 @@ That consumer-side write remains outside this repo.
|
|||
the current dogfood path remains `legacy_api_key` because that is the proven
|
||||
BINKY-WP-0005 header mode.
|
||||
|
||||
## Audit deny-stream reconciliation
|
||||
|
||||
`audit.deny` is a low-volume, load-bearing observation class. Do not infer
|
||||
completeness from a minimum event rate. The service emits `audit.heartbeat` at
|
||||
startup, every `QONTO_AUDIT_HEARTBEAT_INTERVAL_SECONDS` while the process is
|
||||
active (default `86400`), and best-effort at shutdown. Each request event and
|
||||
heartbeat carries `stream_instance_id` plus a monotonic `stream_sequence`.
|
||||
|
||||
For each instance, compare received `audit.allow` / `audit.deny` totals with
|
||||
the heartbeat's `source_transition_counts` and reject sequence gaps. Window
|
||||
counts describe transitions since the prior heartbeat. A quiet deny window has
|
||||
`assertion: nothing-to-report`; a non-quiet one has
|
||||
`assertion: transitions-reported`. Counters are process-local and reset only
|
||||
when `stream_instance_id` changes.
|
||||
|
||||
The authenticated diagnostic view is:
|
||||
|
||||
```text
|
||||
GET /v1/audit/reconciliation
|
||||
```
|
||||
|
||||
It returns only stream identity, timestamps, sequence, and counts. It does not
|
||||
write State Hub, query an observer, include actor/bank data, or increment the
|
||||
stream it describes. The exact contract is declared in
|
||||
`specs/audit-emission-cadence.yaml`.
|
||||
|
||||
## Live authorization gate (flex-auth + tenant-engine)
|
||||
|
||||
Off by default (no `QONTO_FLEX_AUTH_URL` set). When configured, every
|
||||
|
|
|
|||
|
|
@ -416,8 +416,13 @@ Suggested audit envelope:
|
|||
```yaml
|
||||
request_id: req-...
|
||||
timestamp: ...
|
||||
event_class: audit.allow | audit.deny
|
||||
stream_id: qonto-assistant.audit
|
||||
stream_instance_id: ...
|
||||
stream_sequence: ...
|
||||
actor: agt-... / workload id
|
||||
tenant_id: binky
|
||||
identity_binding: self_asserted | key_cape_jwt
|
||||
capability: list_transactions
|
||||
protocol: rest | mcp
|
||||
decision: allow | deny
|
||||
|
|
@ -426,9 +431,15 @@ policy_version: 1
|
|||
latency_ms: ...
|
||||
qonto_http_status: ...
|
||||
result_count: ...
|
||||
egress_destination: qonto-thirdparty-api
|
||||
# never: Authorization header, API_KEY, OpenBao token, full IBAN by default
|
||||
```
|
||||
|
||||
The load-bearing `audit.deny` class uses the source-owned cadence and
|
||||
reconciliation contract in `specs/audit-emission-cadence.yaml`. Heartbeats
|
||||
carry cumulative and per-window source transition counts; observers compare
|
||||
those counts and per-instance sequence numbers with received records.
|
||||
|
||||
### 4.11 Operational guardrails (v1, not "later if we remember")
|
||||
|
||||
Basic safety and reliability controls belong in Phase 1 because one noisy
|
||||
|
|
|
|||
66
specs/audit-emission-cadence.yaml
Normal file
66
specs/audit-emission-cadence.yaml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Source-owned declaration for the qonto-assistant structured audit stream.
|
||||
# The semantic shape follows the Taxonomy draft in
|
||||
# kings-guard/specs/EmissionCadenceDeclaration.md. This repository owns the
|
||||
# concrete emission claim even while the fleet-wide schema remains a draft.
|
||||
|
||||
audit_emission_cadence:
|
||||
schema_version: "0.1"
|
||||
source: qonto-assistant
|
||||
stream_id: qonto-assistant.audit
|
||||
transport: structured-stdout
|
||||
instance_boundary: process
|
||||
|
||||
ordering:
|
||||
instance_field: stream_instance_id
|
||||
sequence_field: stream_sequence
|
||||
sequence_starts_at: 1
|
||||
gap: finding
|
||||
reset: permitted-only-when-stream_instance_id-changes
|
||||
|
||||
event_classes:
|
||||
audit.allow:
|
||||
evidence_class: attributive
|
||||
completeness_claimed: false
|
||||
audit.deny:
|
||||
evidence_class: load-bearing
|
||||
form: heartbeat-or-reconciliation
|
||||
rate_monitoring: forbidden
|
||||
completeness_claimed: true
|
||||
audit.heartbeat:
|
||||
evidence_class: load-bearing
|
||||
role: positive-liveness-and-reconciliation-claim
|
||||
|
||||
heartbeat:
|
||||
event_class: audit.heartbeat
|
||||
interval: PT24H
|
||||
interval_config: QONTO_AUDIT_HEARTBEAT_INTERVAL_SECONDS
|
||||
default_interval_seconds: 86400
|
||||
lifecycle:
|
||||
startup: required
|
||||
periodic_while_process_active: required
|
||||
shutdown: best-effort
|
||||
quiet_assertion: nothing-to-report
|
||||
active_instance_missing: finding
|
||||
scale_to_zero_semantics: >
|
||||
No periodic heartbeat is promised while no process instance exists.
|
||||
Observers track each stream_instance_id from its startup heartbeat until
|
||||
a shutdown heartbeat or expiry after a missing active-instance cadence.
|
||||
|
||||
reconciliation:
|
||||
source_counts_field: source_transition_counts
|
||||
window_counts_field: window_transition_counts
|
||||
counted_classes:
|
||||
- audit.allow
|
||||
- audit.deny
|
||||
snapshot_endpoint: /v1/audit/reconciliation
|
||||
compare_observed: >
|
||||
Count received request events by event_class and stream_instance_id, then
|
||||
compare them with the heartbeat source counts and stream sequence.
|
||||
divergence: finding
|
||||
undrained_local: lag-not-divergence
|
||||
persistence: process-local
|
||||
|
||||
event_context:
|
||||
identity_binding_field: identity_binding
|
||||
egress_destination_field: egress_destination
|
||||
qonto_egress_destination: qonto-thirdparty-api
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
# Security Genome record for qonto-assistant.
|
||||
#
|
||||
# Schema per kings-guard/specs/NetKingdomImmuneArchitecture.md §9.1
|
||||
# ("Minimum Genome Record"). Kings Guard does not exist as a running system
|
||||
# yet — this record is written now so no rework is needed once an admission
|
||||
# or posture-assessment consumer does exist. See docs/SecurityPractice.md.
|
||||
# ("Minimum Genome Record"). King's Guard now consumes this source's structured
|
||||
# audit events; the adjacent cadence declaration supplies stream-completeness
|
||||
# semantics without making the consumer an authority for source intent.
|
||||
|
||||
security_genome_record:
|
||||
id: kg:genome:qonto-assistant
|
||||
|
|
@ -24,7 +24,7 @@ security_genome_record:
|
|||
criticality: high
|
||||
|
||||
identities:
|
||||
workload_identity: "TBD — pending key-cape/workload-identity integration (QONTO-WP-0004)"
|
||||
workload_identity: "key-cape JWT verification supported; runtime enforcement is configuration-bound"
|
||||
deployment_identity: "TBD — pending Railiance placement decision"
|
||||
|
||||
capabilities:
|
||||
|
|
@ -84,3 +84,8 @@ security_genome_record:
|
|||
bound to verified identity. Tolerated until key-cape/flex-auth
|
||||
integration (QONTO-WP-0004) closes this.
|
||||
expires_at: null
|
||||
|
||||
# Source emission claim for the load-bearing deny observation class. The full
|
||||
# machine-readable declaration is kept separate so consumers can ingest it
|
||||
# without interpreting the genome schema.
|
||||
audit_emission_cadence: audit-emission-cadence.yaml
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(",")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from qonto_assistant.app import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from qonto_assistant.app import create_app
|
||||
from qonto_assistant.audit import AuditLogger
|
||||
from qonto_assistant.config import Settings
|
||||
from qonto_assistant.contracts import ActorClaims
|
||||
|
|
@ -12,7 +14,9 @@ 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"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
|
|
@ -38,6 +42,7 @@ def _settings() -> Settings:
|
|||
deny_escalation_threshold=3,
|
||||
deny_escalation_window_seconds=60,
|
||||
deny_escalation_lockout_seconds=300,
|
||||
audit_heartbeat_interval_seconds=86400,
|
||||
key_cape_jwks_url=None,
|
||||
key_cape_issuer="https://key-cape.netkingdom",
|
||||
key_cape_audience="qonto-assistant",
|
||||
|
|
@ -227,3 +232,56 @@ def test_snapshot_contract_returns_cost_run_rate_hints_for_90_day_window(monkeyp
|
|||
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)
|
||||
|
||||
|
||||
async def test_app_lifecycle_and_reconciliation_use_the_request_audit_stream(monkeypatch) -> None:
|
||||
service, events = _service(monkeypatch)
|
||||
app = create_app(settings=_settings(), service=service)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
assert events[-1]["event_class"] == "audit.heartbeat"
|
||||
assert events[-1]["reason"] == "startup"
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
response = await client.get(
|
||||
"/v1/audit/reconciliation",
|
||||
headers={"X-Actor-ID": "observer", "X-Tenant-ID": "binky"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["last_stream_sequence"] == 1
|
||||
assert response.json()["source_transition_counts"] == {
|
||||
"audit.allow": 0,
|
||||
"audit.deny": 0,
|
||||
}
|
||||
assert len(events) == 1
|
||||
|
||||
assert events[-1]["event_class"] == "audit.heartbeat"
|
||||
assert events[-1]["reason"] == "shutdown"
|
||||
assert events[-1]["stream_sequence"] == 2
|
||||
|
||||
|
||||
async def test_client_closes_when_shutdown_heartbeat_fails(monkeypatch) -> None:
|
||||
service, _ = _service(monkeypatch)
|
||||
closed = []
|
||||
monkeypatch.setattr(service.client, "close", lambda: closed.append(True))
|
||||
|
||||
def failing_shutdown(payload):
|
||||
if payload.get("reason") == "shutdown":
|
||||
raise RuntimeError("sink unavailable")
|
||||
|
||||
service.audit_logger.sink = failing_shutdown
|
||||
app = create_app(settings=_settings(), service=service)
|
||||
with pytest.raises(ExceptionGroup) as caught:
|
||||
async with app.router.lifespan_context(app):
|
||||
pass
|
||||
assert caught.group_contains(RuntimeError, match="sink unavailable")
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_app_rejects_split_audit_streams(monkeypatch) -> None:
|
||||
service, _ = _service(monkeypatch)
|
||||
with pytest.raises(ValueError, match="share one audit_logger"):
|
||||
create_app(settings=_settings(), service=service, audit_logger=AuditLogger())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import suppress
|
||||
|
||||
from qonto_assistant.audit import AuditLogger, REDACTED
|
||||
from qonto_assistant.app import _emit_audit_heartbeats
|
||||
from qonto_assistant.audit import AUDIT_STREAM_ID, REDACTED, AuditLogger
|
||||
|
||||
|
||||
def test_audit_logger_redacts_secret_fields() -> None:
|
||||
|
|
@ -23,3 +27,115 @@ def test_audit_logger_redacts_secret_fields() -> None:
|
|||
assert payload["nested"]["token"] == REDACTED
|
||||
assert "super-secret" not in str(events[0])
|
||||
assert "top-secret" not in str(events[0])
|
||||
|
||||
|
||||
def test_audit_logger_sequences_events_and_counts_source_transitions() -> None:
|
||||
events: list[dict[str, object]] = []
|
||||
audit = AuditLogger(sink=events.append, instance_id="instance-1")
|
||||
|
||||
allowed = audit.emit({"decision": "allow", "request_id": "allow-1"})
|
||||
denied = audit.emit({"decision": "deny", "request_id": "deny-1"})
|
||||
snapshot = audit.reconciliation_snapshot()
|
||||
|
||||
assert allowed["event_class"] == "audit.allow"
|
||||
assert denied["event_class"] == "audit.deny"
|
||||
assert [event["stream_sequence"] for event in events] == [1, 2]
|
||||
assert all(event["stream_id"] == AUDIT_STREAM_ID for event in events)
|
||||
assert all(event["stream_instance_id"] == "instance-1" for event in events)
|
||||
assert snapshot["last_stream_sequence"] == 2
|
||||
assert snapshot["source_transition_counts"] == {"audit.allow": 1, "audit.deny": 1}
|
||||
assert snapshot["since_last_heartbeat_counts"] == {"audit.allow": 1, "audit.deny": 1}
|
||||
|
||||
|
||||
def test_heartbeat_reconciles_window_and_resets_only_window_counts() -> None:
|
||||
timestamps = iter(
|
||||
[
|
||||
"2026-09-04T00:00:00+00:00",
|
||||
"2026-09-04T00:01:00+00:00",
|
||||
"2026-09-04T00:02:00+00:00",
|
||||
"2026-09-04T00:03:00+00:00",
|
||||
]
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
audit = AuditLogger(
|
||||
sink=events.append, instance_id="instance-1", clock=lambda: next(timestamps)
|
||||
)
|
||||
|
||||
first = audit.emit_heartbeat(reason="startup")
|
||||
audit.emit({"decision": "deny", "request_id": "deny-1"})
|
||||
second = audit.emit_heartbeat(reason="periodic")
|
||||
snapshot = audit.reconciliation_snapshot()
|
||||
|
||||
assert first["assertion"] == "nothing-to-report"
|
||||
assert first["window_transition_counts"] == {"audit.allow": 0, "audit.deny": 0}
|
||||
assert second["assertion"] == "transitions-reported"
|
||||
assert second["source_transition_counts"] == {"audit.allow": 0, "audit.deny": 1}
|
||||
assert second["window_transition_counts"] == {"audit.allow": 0, "audit.deny": 1}
|
||||
assert second["stream_sequence"] == 3
|
||||
assert snapshot["source_transition_counts"] == {"audit.allow": 0, "audit.deny": 1}
|
||||
assert snapshot["since_last_heartbeat_counts"] == {"audit.allow": 0, "audit.deny": 0}
|
||||
|
||||
|
||||
def test_primary_log_is_written_before_optional_sink_failure(caplog) -> None:
|
||||
def broken_sink(_: dict[str, object]) -> None:
|
||||
raise RuntimeError("secondary sink unavailable")
|
||||
|
||||
audit = AuditLogger(sink=broken_sink, instance_id="instance-1")
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="qonto_assistant.audit"):
|
||||
try:
|
||||
audit.emit({"decision": "deny", "request_id": "deny-1"})
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("Expected the secondary sink error")
|
||||
|
||||
assert '"event_class": "audit.deny"' in caplog.text
|
||||
assert '"stream_sequence": 1' in caplog.text
|
||||
|
||||
|
||||
async def test_periodic_heartbeat_loop_emits_until_cancelled() -> None:
|
||||
events: list[dict[str, object]] = []
|
||||
audit = AuditLogger(sink=events.append, instance_id="instance-1")
|
||||
ready = asyncio.Event()
|
||||
|
||||
def collect(payload):
|
||||
events.append(payload)
|
||||
if len(events) >= 2:
|
||||
ready.set()
|
||||
|
||||
audit.sink = collect
|
||||
task = asyncio.create_task(_emit_audit_heartbeats(audit_logger=audit, interval_seconds=0.01))
|
||||
try:
|
||||
await asyncio.wait_for(ready.wait(), timeout=2)
|
||||
finally:
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert len(events) >= 2
|
||||
assert all(event["event_class"] == "audit.heartbeat" for event in events)
|
||||
assert all(event["reason"] == "periodic" for event in events)
|
||||
|
||||
|
||||
def test_concurrent_transitions_and_heartbeats_preserve_publication_order() -> None:
|
||||
events = []
|
||||
audit = AuditLogger(sink=events.append)
|
||||
|
||||
def publish(index):
|
||||
if index % 3 == 0:
|
||||
audit.emit_heartbeat()
|
||||
else:
|
||||
audit.emit({"decision": "deny"})
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
list(executor.map(publish, range(120)))
|
||||
|
||||
assert [event["stream_sequence"] for event in events] == list(range(1, 121))
|
||||
denies = 0
|
||||
for event in events:
|
||||
if event["event_class"] == "audit.deny":
|
||||
denies += 1
|
||||
else:
|
||||
assert event["source_transition_counts"]["audit.deny"] == denies
|
||||
assert audit.reconciliation_snapshot()["source_transition_counts"]["audit.deny"] == 80
|
||||
|
|
|
|||
|
|
@ -18,18 +18,35 @@ from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
|||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
|
||||
# Fields whose values are expected to vary per call (timing/identifiers) or
|
||||
# by design (protocol). Everything else must match exactly between REST and
|
||||
# MCP for the same logical call.
|
||||
NON_COMPARABLE_FIELDS = {"request_id", "timestamp", "latency_ms", "protocol"}
|
||||
SECRET_FIELD_NAMES = {"api_key", "authorization", "authorization_header", "openbao_token", "secret", "token"}
|
||||
NON_COMPARABLE_FIELDS = {
|
||||
"request_id",
|
||||
"timestamp",
|
||||
"latency_ms",
|
||||
"protocol",
|
||||
"stream_sequence",
|
||||
}
|
||||
SECRET_FIELD_NAMES = {
|
||||
"api_key",
|
||||
"authorization",
|
||||
"authorization_header",
|
||||
"openbao_token",
|
||||
"secret",
|
||||
"token",
|
||||
}
|
||||
|
||||
|
||||
def _service() -> tuple[CapabilityService, list[dict[str, object]]]:
|
||||
events: list[dict[str, object]] = []
|
||||
policy = PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False)
|
||||
policy = PolicyEngine.from_file(
|
||||
POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False
|
||||
)
|
||||
service = CapabilityService(
|
||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||
policy=policy,
|
||||
|
|
@ -56,10 +73,14 @@ def test_allow_path_audit_schema_identical_across_protocols() -> None:
|
|||
assert rest_event["protocol"] == "rest"
|
||||
assert mcp_event["protocol"] == "mcp"
|
||||
for field in set(rest_event) - NON_COMPARABLE_FIELDS:
|
||||
assert rest_event[field] == mcp_event[field], f"field {field!r} diverged: {rest_event[field]!r} != {mcp_event[field]!r}"
|
||||
assert rest_event[field] == mcp_event[field], (
|
||||
f"field {field!r} diverged: {rest_event[field]!r} != {mcp_event[field]!r}"
|
||||
)
|
||||
|
||||
assert rest_event["decision"] == "allow"
|
||||
assert rest_event["capability"] == "org_summary"
|
||||
assert rest_event["identity_binding"] == "self_asserted"
|
||||
assert rest_event["egress_destination"] == "qonto-thirdparty-api"
|
||||
|
||||
|
||||
def test_deny_path_audit_schema_identical_across_protocols() -> None:
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ from qonto_assistant.security_watch import (
|
|||
)
|
||||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
|
||||
|
||||
|
|
@ -53,7 +55,9 @@ def _service(tracker: DenyEscalationTracker, events: list[dict[str, object]]) ->
|
|||
|
||||
def test_tracker_allows_denies_below_threshold() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=3, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
|
@ -63,7 +67,9 @@ def test_tracker_allows_denies_below_threshold() -> None:
|
|||
|
||||
def test_tracker_locks_out_after_threshold_within_window() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=3, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
|
||||
for _ in range(3):
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
|
@ -74,7 +80,9 @@ def test_tracker_locks_out_after_threshold_within_window() -> None:
|
|||
|
||||
def test_tracker_ignores_non_escalating_deny_reasons() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=3, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
|
||||
for _ in range(10):
|
||||
tracker.record_deny("binky:prober", "tenant_scope")
|
||||
|
|
@ -85,7 +93,9 @@ def test_tracker_ignores_non_escalating_deny_reasons() -> None:
|
|||
|
||||
def test_tracker_denies_outside_window_do_not_accumulate() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=3, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
clock.advance(61)
|
||||
|
|
@ -98,7 +108,9 @@ def test_tracker_denies_outside_window_do_not_accumulate() -> None:
|
|||
|
||||
def test_tracker_lockout_expires_after_lockout_window() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=3, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
|
||||
for _ in range(3):
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
|
@ -118,7 +130,9 @@ def test_tracker_lockout_expires_after_lockout_window() -> None:
|
|||
|
||||
def test_tracker_is_scoped_per_actor() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=3, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
|
||||
for _ in range(3):
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
|
@ -131,7 +145,9 @@ def test_tracker_is_scoped_per_actor() -> None:
|
|||
|
||||
def test_service_locks_out_actor_after_repeated_arg_constraint_denies() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=2, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=2, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
service = _service(tracker, events)
|
||||
|
||||
|
|
@ -164,7 +180,9 @@ def test_service_locks_out_actor_after_repeated_arg_constraint_denies() -> None:
|
|||
|
||||
def test_service_does_not_lock_out_for_ordinary_denies() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=2, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=2, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
service = _service(tracker, events)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ def _client(handler) -> FlexAuthCheckClient:
|
|||
|
||||
def test_allow_effect_authorizes() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
|
||||
)
|
||||
|
||||
assert _client(handler).is_allowed(_request()) is True
|
||||
|
||||
|
|
@ -33,7 +36,10 @@ def test_allow_effect_authorizes() -> None:
|
|||
@pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"])
|
||||
def test_non_allow_effects_deny(effect: str) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}},
|
||||
)
|
||||
|
||||
assert _client(handler).is_allowed(_request()) is False
|
||||
|
||||
|
|
@ -78,7 +84,10 @@ def test_request_body_matches_schema_shape() -> None:
|
|||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.update(json.loads(request.content))
|
||||
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
|
||||
)
|
||||
|
||||
_client(handler).is_allowed(_request())
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ def _token(private_key: rsa.RSAPrivateKey, *, kid: str = KID, **claim_overrides)
|
|||
return jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": kid})
|
||||
|
||||
|
||||
def _verifier(jwk: dict, *, required: bool = False, calls: list[int] | None = None) -> KeyCapeTokenVerifier:
|
||||
def _verifier(
|
||||
jwk: dict, *, required: bool = False, calls: list[int] | None = None
|
||||
) -> KeyCapeTokenVerifier:
|
||||
return KeyCapeTokenVerifier(
|
||||
jwks_url="https://key-cape.netkingdom.test/jwks",
|
||||
issuer=ISSUER,
|
||||
|
|
@ -71,6 +73,7 @@ def test_verify_accepts_valid_token() -> None:
|
|||
|
||||
assert claims.actor_id == "agent-harness-binky"
|
||||
assert claims.tenant_id == "tenant:friendly:binky"
|
||||
assert claims.identity_binding == "key_cape_jwt"
|
||||
assert claims.lane == "blue"
|
||||
assert "finance.qonto.read" in claims.scopes
|
||||
|
||||
|
|
@ -194,6 +197,7 @@ def _settings() -> Settings:
|
|||
deny_escalation_threshold=3,
|
||||
deny_escalation_window_seconds=60,
|
||||
deny_escalation_lockout_seconds=300,
|
||||
audit_heartbeat_interval_seconds=86400,
|
||||
key_cape_jwks_url=None,
|
||||
key_cape_issuer=ISSUER,
|
||||
key_cape_audience=AUDIENCE,
|
||||
|
|
@ -236,7 +240,9 @@ def test_required_verifier_rejects_missing_bearer_token() -> None:
|
|||
verifier = _verifier(jwk, required=True)
|
||||
|
||||
with pytest.raises(KeyCapeAuthError):
|
||||
actor_claims_from_headers({"x-actor-id": "someone"}, _settings(), key_cape_verifier=verifier)
|
||||
actor_claims_from_headers(
|
||||
{"x-actor-id": "someone"}, _settings(), key_cape_verifier=verifier
|
||||
)
|
||||
|
||||
|
||||
def test_optional_verifier_falls_back_to_self_asserted_headers_when_absent() -> None:
|
||||
|
|
@ -249,6 +255,7 @@ def test_optional_verifier_falls_back_to_self_asserted_headers_when_absent() ->
|
|||
|
||||
assert claims.actor_id == "someone"
|
||||
assert claims.tenant_id == "binky"
|
||||
assert claims.identity_binding == "self_asserted"
|
||||
|
||||
|
||||
def test_invalid_bearer_token_is_rejected_even_when_not_required() -> None:
|
||||
|
|
|
|||
|
|
@ -7,14 +7,20 @@ from qonto_assistant.audit import AuditLogger
|
|||
from qonto_assistant.contracts import ActorClaims
|
||||
from qonto_assistant.errors import PolicyDeniedError
|
||||
from qonto_assistant.flex_auth_client import FlexAuthCheckClient
|
||||
from qonto_assistant.live_authorization import DENY_LIVE_AUTHZ, DENY_TENANT_ROLE, LiveAuthorizationGate
|
||||
from qonto_assistant.live_authorization import (
|
||||
DENY_LIVE_AUTHZ,
|
||||
DENY_TENANT_ROLE,
|
||||
LiveAuthorizationGate,
|
||||
)
|
||||
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
|
||||
from qonto_assistant.tenant_engine_client import TenantEngineClient
|
||||
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
|
||||
|
||||
|
|
@ -24,16 +30,23 @@ def _claims(tenant_id: str = "tenant:friendly:binky") -> ActorClaims:
|
|||
|
||||
def _flex_auth_client(effect: str) -> FlexAuthCheckClient:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}},
|
||||
)
|
||||
|
||||
return FlexAuthCheckClient(base_url="https://flex-auth.test", transport=httpx.MockTransport(handler))
|
||||
return FlexAuthCheckClient(
|
||||
base_url="https://flex-auth.test", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
|
||||
def _tenant_engine_client(roles: list[str]) -> TenantEngineClient:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"tenant_id": "tenant:friendly:binky", "roles": roles})
|
||||
|
||||
return TenantEngineClient(base_url="https://tenant-engine.test", transport=httpx.MockTransport(handler))
|
||||
return TenantEngineClient(
|
||||
base_url="https://tenant-engine.test", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
|
||||
# --- Gate unit tests -------------------------------------------------------------------
|
||||
|
|
@ -95,7 +108,9 @@ def test_gate_denies_when_tenant_engine_unreachable() -> None:
|
|||
|
||||
gate = LiveAuthorizationGate(
|
||||
flex_auth_client=_flex_auth_client("allow"),
|
||||
tenant_engine_client=TenantEngineClient(base_url="https://tenant-engine.test", transport=httpx.MockTransport(handler)),
|
||||
tenant_engine_client=TenantEngineClient(
|
||||
base_url="https://tenant-engine.test", transport=httpx.MockTransport(handler)
|
||||
),
|
||||
required_tenant_roles=frozenset({"VEN"}),
|
||||
)
|
||||
|
||||
|
|
@ -108,7 +123,9 @@ def test_gate_denies_when_tenant_engine_unreachable() -> None:
|
|||
def _service(gate: LiveAuthorizationGate, events: list[dict[str, object]]) -> CapabilityService:
|
||||
return CapabilityService(
|
||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||
policy=PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False),
|
||||
policy=PolicyEngine.from_file(
|
||||
POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False
|
||||
),
|
||||
audit_logger=AuditLogger(sink=events.append),
|
||||
rate_limiter=RateLimiter(limit=20, window_seconds=60),
|
||||
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||
|
|
@ -151,7 +168,9 @@ def test_service_without_gate_configured_behaves_as_before() -> None:
|
|||
events: list[dict[str, object]] = []
|
||||
service = CapabilityService(
|
||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||
policy=PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False),
|
||||
policy=PolicyEngine.from_file(
|
||||
POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False
|
||||
),
|
||||
audit_logger=AuditLogger(sink=events.append),
|
||||
rate_limiter=RateLimiter(limit=20, window_seconds=60),
|
||||
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
|||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
|
|
@ -21,7 +23,9 @@ def _settings() -> Settings:
|
|||
|
||||
def _service() -> tuple[CapabilityService, list[dict[str, object]]]:
|
||||
events: list[dict[str, object]] = []
|
||||
policy = PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False)
|
||||
policy = PolicyEngine.from_file(
|
||||
POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False
|
||||
)
|
||||
service = CapabilityService(
|
||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||
policy=policy,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import pytest
|
|||
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"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
|
||||
|
||||
def _policy(*, enforce_scope: bool = False) -> PolicyEngine:
|
||||
|
|
@ -55,21 +57,31 @@ def test_policy_denies_cross_tenant_requests() -> None:
|
|||
|
||||
|
||||
def test_policy_denies_excessive_page_size() -> None:
|
||||
decision = _policy().decide(_request("list_transactions", page=1, page_size=101, window_days=31))
|
||||
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")
|
||||
_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))
|
||||
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"
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ 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"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ def _client(handler) -> TenantEngineClient:
|
|||
def test_active_roles_returns_roles_on_200() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/tenants/tenant:friendly:binky/roles/live"
|
||||
return httpx.Response(200, json={"tenant_id": "tenant:friendly:binky", "roles": ["VEN", "CUS"]})
|
||||
return httpx.Response(
|
||||
200, json={"tenant_id": "tenant:friendly:binky", "roles": ["VEN", "CUS"]}
|
||||
)
|
||||
|
||||
roles = _client(handler).active_roles("tenant:friendly:binky")
|
||||
|
||||
|
|
|
|||
25
workplans/ADHOC-2026-09-05.md
Normal file
25
workplans/ADHOC-2026-09-05.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
id: ADHOC-2026-09-05
|
||||
type: workplan
|
||||
title: "Clear verification lint failures and stale scope status"
|
||||
domain: infotech
|
||||
repo: qonto-assistant
|
||||
status: finished
|
||||
owner: codex
|
||||
created: "2026-09-05"
|
||||
updated: "2026-09-05"
|
||||
---
|
||||
|
||||
## Restore clean static checks
|
||||
|
||||
```task
|
||||
id: ADHOC-2026-09-05-T01
|
||||
status: done
|
||||
priority: low
|
||||
```
|
||||
|
||||
Applied Ruff import and formatting corrections to existing source/tests and
|
||||
shortened one docstring to clear the 44 repository lint failures. Corrected
|
||||
SCOPE.md's stale runtime-not-started entry. Full Ruff checks and the 88-test
|
||||
suite pass using the existing venv directly. Make bootstrap remains dependent
|
||||
on pip, which this workstation's existing venv does not contain.
|
||||
107
workplans/QONTO-WP-0005-audit-deny-stream-completeness.md
Normal file
107
workplans/QONTO-WP-0005-audit-deny-stream-completeness.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
---
|
||||
id: QONTO-WP-0005
|
||||
type: workplan
|
||||
title: "Establish audit.deny stream completeness evidence"
|
||||
domain: infotech
|
||||
repo: qonto-assistant
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: qonto-audit-deny-stream-completeness
|
||||
created: "2026-09-04"
|
||||
updated: "2026-09-05"
|
||||
state_hub_workstream_id: "8a1bc02c-00fc-5400-af1f-10af562b9782"
|
||||
---
|
||||
|
||||
# Establish `audit.deny` stream completeness evidence
|
||||
|
||||
Closes the qonto-assistant-owned remediation from `RISK-F-0011` and the
|
||||
King's Guard message received on 2026-09-01. A received deny event is already
|
||||
useful, but observers cannot currently distinguish a quiet stream from an
|
||||
incomplete one. The source therefore needs to publish its cadence, emit a
|
||||
positive heartbeat carrying source-side counts, and expose a reconciliation
|
||||
snapshot that makes missing events and sequence gaps visible.
|
||||
|
||||
The implementation stays local to the structured audit stream. State Hub is
|
||||
not placed on the request hot path, and the existing in-process deny escalation
|
||||
loop remains independent from estate observation.
|
||||
|
||||
## Task: Declare cadence and reconciliation semantics
|
||||
|
||||
```task
|
||||
id: QONTO-WP-0005-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "e846d42b-d9e0-587e-874b-0ad8c1d5c471"
|
||||
```
|
||||
|
||||
Publish the `audit.deny` load-bearing classification next to the security
|
||||
genome. Declare heartbeat behavior, active-instance lifecycle semantics,
|
||||
source-side counters, sequence-gap detection, and what an observer must compare.
|
||||
|
||||
Done when the declaration is machine-readable and the operator documentation
|
||||
does not imply that a received event alone proves stream completeness.
|
||||
|
||||
**Done 2026-09-04:** Added `specs/audit-emission-cadence.yaml` and linked it
|
||||
from the security genome. It declares the per-process sequence boundary,
|
||||
`audit.deny` as low-volume/load-bearing, the 24-hour active-process cadence,
|
||||
scale-to-zero lifecycle semantics, cumulative/window counters, and observer
|
||||
comparison rules. The architecture, security practice, and operator runbook
|
||||
now carry the same semantics.
|
||||
|
||||
## Task: Emit heartbeat and reconciliation evidence
|
||||
|
||||
```task
|
||||
id: QONTO-WP-0005-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "cdb6576a-f606-57c9-94b2-923f23c2044b"
|
||||
```
|
||||
|
||||
Add process-instance identity, monotonic stream sequence, per-class transition
|
||||
counts, periodic/startup/shutdown heartbeat events, and a read-only local
|
||||
reconciliation view. Enrich request events with their identity binding and
|
||||
declared egress destination so observers no longer substitute genome constants.
|
||||
|
||||
Done when the source emits enough information to detect a missing deny or
|
||||
heartbeat without introducing State Hub or another network dependency.
|
||||
|
||||
**Done 2026-09-04:** `AuditLogger` now serializes publication under one lock,
|
||||
assigns a UUID process instance and monotonic sequence, counts allow/deny
|
||||
source transitions, and emits startup/periodic/best-effort-shutdown heartbeats
|
||||
with reconciliation counters. `GET /v1/audit/reconciliation` returns the same
|
||||
non-secret local state without incrementing it. REST/MCP request records carry
|
||||
`identity_binding` (`self_asserted` or `key_cape_jwt`) and
|
||||
`egress_destination: qonto-thirdparty-api`. The app rejects split injected
|
||||
audit loggers so request events and heartbeat evidence cannot silently land in
|
||||
different streams.
|
||||
|
||||
## Task: Verify and hand back the finding
|
||||
|
||||
```task
|
||||
id: QONTO-WP-0005-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "e5ed80bd-f0aa-5473-a185-ecb9f87ab21c"
|
||||
```
|
||||
|
||||
Cover sequencing, counts, heartbeat windows, reconciliation output, REST/MCP
|
||||
parity, identity binding, and secret redaction in tests. Run the full local
|
||||
suite, sync the workplan to State Hub, and notify Risk Nexus and King's Guard
|
||||
with the source-owned evidence and any remaining deployment observation gate.
|
||||
|
||||
**Done 2026-09-04:** Full suite passed (`85 passed`); changed-file Ruff checks,
|
||||
YAML parsing, `compileall`, `git diff --check`, REST smoke, and MCP smoke all
|
||||
passed. Both smoke runs visibly emitted startup/shutdown heartbeats with
|
||||
contiguous sequences and matching counts. Source implementation is complete;
|
||||
deployment and independent observer acceptance remain with the runtime owner,
|
||||
King's Guard, and Risk Nexus rather than as unfinished source work here.
|
||||
|
||||
|
||||
**Review 2026-09-05:** Reviewed the pending implementation against its cadence
|
||||
contract. Fixed client cleanup when shutdown audit publication fails, added
|
||||
regressions for cleanup and split stream rejection, and verified concurrent
|
||||
heartbeat/request publication ordering. Replaced timing-sensitive periodic test
|
||||
sleep with an explicit event. Final verification: 88 tests passed, full-source
|
||||
Ruff, compileall, YAML parsing, and REST/MCP fixture smoke checks passed. Runtime
|
||||
deployment and independent observer acceptance remain external gates. No new
|
||||
cross-repo messages were sent during this review.
|
||||
Loading…
Add table
Add a link
Reference in a new issue