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 |
|
| INTENT / SCOPE / AGENTS | present |
|
||||||
| Architecture blueprint | `specs/ArchitectureBlueprint.md` |
|
| Architecture blueprint | `specs/ArchitectureBlueprint.md` |
|
||||||
| Research notes | `research/2026-07-21-…` |
|
| 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) |
|
| Upstream custody | live in platform (`binky-qonto-api`, CCR-2026-0008) |
|
||||||
|
|
||||||
## Getting oriented
|
## Getting oriented
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,16 @@
|
||||||
|
|
||||||
| Kind | ID | Status | Lane | Source |
|
| 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-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-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-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 |
|
| 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 |
|
| workplan | QONTO-WP-0005 | finished | — | workplans/QONTO-WP-0005-audit-deny-stream-completeness.md |
|
||||||
| task | ADHOC-2026-07-21-T02 | done | — | workplans/ADHOC-2026-07-21.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-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-T02 | done | — | workplans/QONTO-WP-0001-statehub-bootstrap.md |
|
||||||
| task | QONTO-WP-0001-T03 | done | — | workplans/QONTO-WP-0001-statehub-bootstrap.md |
|
| task | QONTO-WP-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-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-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-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_RATE_LIMIT_WINDOW_SECONDS: "60"
|
||||||
QONTO_MAX_CONCURRENCY: "4"
|
QONTO_MAX_CONCURRENCY: "4"
|
||||||
QONTO_DENY_ESCALATION_ENABLED: "true"
|
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_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_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
|
# 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`
|
recovery procedure with no data-loss risk (`maximum_data_loss: PT0S`
|
||||||
applies trivially since there is no persisted data).
|
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
|
King's Guard has observed the structured allow/deny stream. The source keeps
|
||||||
`qonto-assistant` needs zero rework once a sentinel-mesh or decision plane
|
ownership of its genome and emission claims; the observer independently checks
|
||||||
does exist.
|
the records and completeness evidence it receives.
|
||||||
|
|
||||||
### 9.1 Security Genome record
|
### 9.1 Security Genome record
|
||||||
|
|
||||||
|
|
@ -210,21 +210,29 @@ nothing else), data classification, and recovery expectations.
|
||||||
|
|
||||||
### 9.2 Audit stream is already observation-shaped
|
### 9.2 Audit stream is already observation-shaped
|
||||||
|
|
||||||
`AuditLogger`'s existing event shape (actor, capability, decision,
|
`AuditLogger`'s event shape includes actor, capability, decision, deny reason,
|
||||||
deny_reason, latency, upstream HTTP status, policy version — see
|
latency, upstream HTTP status, policy version, identity binding, and egress
|
||||||
`tests/test_audit.py`, `tests/test_audit_parity.py`) already matches Kings
|
destination (see `tests/test_audit.py`, `tests/test_audit_parity.py`). Every
|
||||||
Guard's Immune Observation contract closely enough that no schema rework
|
record also carries a process-instance id and monotonic stream sequence.
|
||||||
should be needed later — just a new consumer pointed at the same stream.
|
|
||||||
|
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
|
### 9.3 A concrete, actionable-today signal
|
||||||
|
|
||||||
This does not require any Kings Guard component: repeated
|
This does not require any Kings Guard component: repeated
|
||||||
`arg_constraint` or `credential_exfil` deny reasons from the same actor
|
`arg_constraint` or `credential_exfil` deny reasons from the same actor
|
||||||
within a short window is a real, current signal. It should trip a
|
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
|
tightened rate limit or a temporary lockout for that actor now. The current
|
||||||
audit stream and rate limiter that already exist — this is a Fast Local
|
`DenyEscalationTracker` branches directly on the same decision path that emits
|
||||||
Loop (`NetKingdomImmuneArchitecture.md` §14.1) response that does not need
|
the audit event; it does not consume the audit stream. This is a Fast Local Loop
|
||||||
to wait for any future component.
|
(`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 |
|
| Security Genome record | `qonto-assistant` | can ship now |
|
||||||
| Audit-stream shape review against Immune Observation contract | `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 |
|
| 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 |
|
| `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` | needs the resource registered in `flex-auth` |
|
| `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` | needs a `tenant-engine` lookup/cache API call wired in |
|
| 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 |
|
| 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 |
|
| 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
|
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).
|
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/transactions`
|
||||||
- policy-gated `GET /v1/snapshot`
|
- policy-gated `GET /v1/snapshot`
|
||||||
- structured audit events without secrets
|
- structured audit events without secrets
|
||||||
|
- sequenced audit heartbeat and reconciliation evidence
|
||||||
- env-backed or OpenBao-CLI-backed credential loading
|
- env-backed or OpenBao-CLI-backed credential loading
|
||||||
|
|
||||||
Spend, transfer, card, invoicing, payment-link, and other volume-cost actions
|
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
|
the current dogfood path remains `legacy_api_key` because that is the proven
|
||||||
BINKY-WP-0005 header mode.
|
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)
|
## Live authorization gate (flex-auth + tenant-engine)
|
||||||
|
|
||||||
Off by default (no `QONTO_FLEX_AUTH_URL` set). When configured, every
|
Off by default (no `QONTO_FLEX_AUTH_URL` set). When configured, every
|
||||||
|
|
|
||||||
|
|
@ -416,8 +416,13 @@ Suggested audit envelope:
|
||||||
```yaml
|
```yaml
|
||||||
request_id: req-...
|
request_id: req-...
|
||||||
timestamp: ...
|
timestamp: ...
|
||||||
|
event_class: audit.allow | audit.deny
|
||||||
|
stream_id: qonto-assistant.audit
|
||||||
|
stream_instance_id: ...
|
||||||
|
stream_sequence: ...
|
||||||
actor: agt-... / workload id
|
actor: agt-... / workload id
|
||||||
tenant_id: binky
|
tenant_id: binky
|
||||||
|
identity_binding: self_asserted | key_cape_jwt
|
||||||
capability: list_transactions
|
capability: list_transactions
|
||||||
protocol: rest | mcp
|
protocol: rest | mcp
|
||||||
decision: allow | deny
|
decision: allow | deny
|
||||||
|
|
@ -426,9 +431,15 @@ policy_version: 1
|
||||||
latency_ms: ...
|
latency_ms: ...
|
||||||
qonto_http_status: ...
|
qonto_http_status: ...
|
||||||
result_count: ...
|
result_count: ...
|
||||||
|
egress_destination: qonto-thirdparty-api
|
||||||
# never: Authorization header, API_KEY, OpenBao token, full IBAN by default
|
# 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")
|
### 4.11 Operational guardrails (v1, not "later if we remember")
|
||||||
|
|
||||||
Basic safety and reliability controls belong in Phase 1 because one noisy
|
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.
|
# Security Genome record for qonto-assistant.
|
||||||
#
|
#
|
||||||
# Schema per kings-guard/specs/NetKingdomImmuneArchitecture.md §9.1
|
# Schema per kings-guard/specs/NetKingdomImmuneArchitecture.md §9.1
|
||||||
# ("Minimum Genome Record"). Kings Guard does not exist as a running system
|
# ("Minimum Genome Record"). King's Guard now consumes this source's structured
|
||||||
# yet — this record is written now so no rework is needed once an admission
|
# audit events; the adjacent cadence declaration supplies stream-completeness
|
||||||
# or posture-assessment consumer does exist. See docs/SecurityPractice.md.
|
# semantics without making the consumer an authority for source intent.
|
||||||
|
|
||||||
security_genome_record:
|
security_genome_record:
|
||||||
id: kg:genome:qonto-assistant
|
id: kg:genome:qonto-assistant
|
||||||
|
|
@ -24,7 +24,7 @@ security_genome_record:
|
||||||
criticality: high
|
criticality: high
|
||||||
|
|
||||||
identities:
|
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"
|
deployment_identity: "TBD — pending Railiance placement decision"
|
||||||
|
|
||||||
capabilities:
|
capabilities:
|
||||||
|
|
@ -84,3 +84,8 @@ security_genome_record:
|
||||||
bound to verified identity. Tolerated until key-cape/flex-auth
|
bound to verified identity. Tolerated until key-cape/flex-auth
|
||||||
integration (QONTO-WP-0004) closes this.
|
integration (QONTO-WP-0004) closes this.
|
||||||
expires_at: null
|
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 __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncIterator, Callable
|
import asyncio
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
@ -39,8 +40,13 @@ def create_app(
|
||||||
key_cape_verifier: KeyCapeTokenVerifier | None = None,
|
key_cape_verifier: KeyCapeTokenVerifier | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
settings = settings or Settings.from_env()
|
settings = settings or Settings.from_env()
|
||||||
|
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()
|
audit_logger = audit_logger or AuditLogger()
|
||||||
service = service or _build_service(
|
service = _build_service(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
audit_logger=audit_logger,
|
audit_logger=audit_logger,
|
||||||
rate_limiter=rate_limiter,
|
rate_limiter=rate_limiter,
|
||||||
|
|
@ -57,7 +63,9 @@ def create_app(
|
||||||
cache_seconds=settings.key_cape_cache_seconds,
|
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()
|
mcp_app = mcp_server.streamable_http_app()
|
||||||
if settings.mcp_auth_token:
|
if settings.mcp_auth_token:
|
||||||
mcp_app.add_middleware(BearerTokenAuthMiddleware, token=settings.mcp_auth_token)
|
mcp_app.add_middleware(BearerTokenAuthMiddleware, token=settings.mcp_auth_token)
|
||||||
|
|
@ -66,7 +74,22 @@ def create_app(
|
||||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
|
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
|
||||||
|
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
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await heartbeat_task
|
||||||
|
audit_logger.emit_heartbeat(reason="shutdown")
|
||||||
|
finally:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
service.client.close()
|
service.client.close()
|
||||||
|
|
||||||
|
|
@ -91,11 +114,21 @@ def create_app(
|
||||||
"policy_file": str(settings.policy_file),
|
"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")
|
@app.get("/v1/accounts")
|
||||||
async def get_accounts(request: Request) -> JSONResponse:
|
async def get_accounts(request: Request) -> JSONResponse:
|
||||||
claims = actor_claims_from_request(request, settings, key_cape_verifier=key_cape_verifier)
|
claims = actor_claims_from_request(request, settings, key_cape_verifier=key_cape_verifier)
|
||||||
request_id = _request_id(request)
|
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})
|
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
|
||||||
|
|
||||||
@app.get("/v1/transactions")
|
@app.get("/v1/transactions")
|
||||||
|
|
@ -143,6 +176,12 @@ def create_app(
|
||||||
return 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:
|
def _request_id(request: Request) -> str:
|
||||||
existing = getattr(request.state, "request_id", None)
|
existing = getattr(request.state, "request_id", None)
|
||||||
if existing:
|
if existing:
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
|
from copy import deepcopy
|
||||||
from dataclasses import asdict, is_dataclass
|
from dataclasses import asdict, is_dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from threading import RLock
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from qonto_assistant.contracts import AuditEvent
|
from qonto_assistant.contracts import AuditEvent
|
||||||
|
|
||||||
|
|
@ -18,6 +21,8 @@ SECRET_KEYS = {
|
||||||
"secret",
|
"secret",
|
||||||
"token",
|
"token",
|
||||||
}
|
}
|
||||||
|
AUDIT_STREAM_ID = "qonto-assistant.audit"
|
||||||
|
AUDIT_EVENT_CLASSES = ("audit.allow", "audit.deny")
|
||||||
|
|
||||||
|
|
||||||
def utc_now_iso() -> str:
|
def utc_now_iso() -> str:
|
||||||
|
|
@ -40,19 +45,107 @@ def _sanitize(value: Any) -> Any:
|
||||||
|
|
||||||
|
|
||||||
class AuditLogger:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
logger: logging.Logger | None = None,
|
logger: logging.Logger | None = None,
|
||||||
sink: Callable[[dict[str, Any]], None] | None = None,
|
sink: Callable[[dict[str, Any]], None] | None = None,
|
||||||
|
instance_id: str | None = None,
|
||||||
|
clock: Callable[[], str] = utc_now_iso,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.logger = logger or logging.getLogger("qonto_assistant.audit")
|
self.logger = logger or logging.getLogger("qonto_assistant.audit")
|
||||||
self.logger.setLevel(logging.INFO)
|
self.logger.setLevel(logging.INFO)
|
||||||
self.sink = sink
|
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]:
|
def emit(self, event: AuditEvent | Mapping[str, Any]) -> dict[str, Any]:
|
||||||
payload = _sanitize(asdict(event) if is_dataclass(event) else dict(event))
|
payload = _sanitize(asdict(event) if is_dataclass(event) else dict(event))
|
||||||
if self.sink is not None:
|
decision = payload.get("decision")
|
||||||
self.sink(payload)
|
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))
|
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_threshold: int
|
||||||
deny_escalation_window_seconds: int
|
deny_escalation_window_seconds: int
|
||||||
deny_escalation_lockout_seconds: int
|
deny_escalation_lockout_seconds: int
|
||||||
|
audit_heartbeat_interval_seconds: int
|
||||||
key_cape_jwks_url: str | None
|
key_cape_jwks_url: str | None
|
||||||
key_cape_issuer: str
|
key_cape_issuer: str
|
||||||
key_cape_audience: str
|
key_cape_audience: str
|
||||||
|
|
@ -81,10 +82,18 @@ class Settings:
|
||||||
rate_limit_requests=int(os.getenv("QONTO_RATE_LIMIT_REQUESTS", "20")),
|
rate_limit_requests=int(os.getenv("QONTO_RATE_LIMIT_REQUESTS", "20")),
|
||||||
rate_limit_window_seconds=int(os.getenv("QONTO_RATE_LIMIT_WINDOW_SECONDS", "60")),
|
rate_limit_window_seconds=int(os.getenv("QONTO_RATE_LIMIT_WINDOW_SECONDS", "60")),
|
||||||
max_concurrency=int(os.getenv("QONTO_MAX_CONCURRENCY", "4")),
|
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_threshold=int(os.getenv("QONTO_DENY_ESCALATION_THRESHOLD", "3")),
|
||||||
deny_escalation_window_seconds=int(os.getenv("QONTO_DENY_ESCALATION_WINDOW_SECONDS", "60")),
|
deny_escalation_window_seconds=int(
|
||||||
deny_escalation_lockout_seconds=int(os.getenv("QONTO_DENY_ESCALATION_LOCKOUT_SECONDS", "300")),
|
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_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_issuer=os.getenv("QONTO_KEY_CAPE_ISSUER", "https://key-cape.netkingdom"),
|
||||||
key_cape_audience=os.getenv("QONTO_KEY_CAPE_AUDIENCE", "qonto-assistant"),
|
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_base_url=os.getenv("QONTO_FLEX_AUTH_URL") or None,
|
||||||
flex_auth_timeout_seconds=float(os.getenv("QONTO_FLEX_AUTH_TIMEOUT_SECONDS", "3")),
|
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_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(
|
tenant_engine_required_roles=frozenset(
|
||||||
role.strip()
|
role.strip()
|
||||||
for role in os.getenv("QONTO_TENANT_ENGINE_REQUIRED_ROLES", "VEN,CUS").split(",")
|
for role in os.getenv("QONTO_TENANT_ENGINE_REQUIRED_ROLES", "VEN,CUS").split(",")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class ActorClaims:
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
lane: str = "green"
|
lane: str = "green"
|
||||||
scopes: frozenset[str] = field(default_factory=frozenset)
|
scopes: frozenset[str] = field(default_factory=frozenset)
|
||||||
|
identity_binding: str = "self_asserted"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
@ -52,5 +53,7 @@ class AuditEvent:
|
||||||
deny_reason: str | None
|
deny_reason: str | None
|
||||||
policy_version: int
|
policy_version: int
|
||||||
latency_ms: int
|
latency_ms: int
|
||||||
|
identity_binding: str
|
||||||
|
egress_destination: str
|
||||||
qonto_http_status: int | None = None
|
qonto_http_status: int | None = None
|
||||||
result_count: int | None = None
|
result_count: int | None = None
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,9 @@ class OpenBaoCliCredentialProvider:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def build_credential_provider(settings: Settings) -> EnvironmentCredentialProvider | OpenBaoCliCredentialProvider:
|
def build_credential_provider(
|
||||||
|
settings: Settings,
|
||||||
|
) -> EnvironmentCredentialProvider | OpenBaoCliCredentialProvider:
|
||||||
if settings.credential_source == "bao-cli":
|
if settings.credential_source == "bao-cli":
|
||||||
return OpenBaoCliCredentialProvider(
|
return OpenBaoCliCredentialProvider(
|
||||||
command=settings.openbao_command,
|
command=settings.openbao_command,
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ class KeyCapeTokenVerifier:
|
||||||
tenant_id=str(claims["tenant"]),
|
tenant_id=str(claims["tenant"]),
|
||||||
lane=_lane_from_roles(claims.get("roles"), default=self.default_lane),
|
lane=_lane_from_roles(claims.get("roles"), default=self.default_lane),
|
||||||
scopes=frozenset(_coerce_scopes(claims.get("scope") or claims.get("scp"))),
|
scopes=frozenset(_coerce_scopes(claims.get("scope") or claims.get("scp"))),
|
||||||
|
identity_binding="key_cape_jwt",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _ensure_keys(self) -> None:
|
def _ensure_keys(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
from qonto_assistant.app import main
|
from qonto_assistant.app import main
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ def create_mcp_server(
|
||||||
window_days: int = 90,
|
window_days: int = 90,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
) -> dict[str, Any]:
|
) -> 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)
|
claims = _claims(ctx, settings, key_cape_verifier)
|
||||||
return service.get_cost_run_rate_hints(
|
return service.get_cost_run_rate_hints(
|
||||||
claims=claims,
|
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()
|
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)
|
headers = _headers_from_context(ctx)
|
||||||
return actor_claims_from_headers(headers, settings, key_cape_verifier=key_cape_verifier)
|
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:
|
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.config = dict(config)
|
||||||
self.version = int(self.config.get("version", 1))
|
self.version = int(self.config.get("version", 1))
|
||||||
self.required_scope = required_scope
|
self.required_scope = required_scope
|
||||||
|
|
@ -54,7 +56,9 @@ class PolicyEngine:
|
||||||
request_args=dict(request.request_args),
|
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", {}))
|
constraints = dict(capability.get("constraints", {}))
|
||||||
checks = {
|
checks = {
|
||||||
"page_size": ("max_per_page", 1),
|
"page_size": ("max_per_page", 1),
|
||||||
|
|
@ -78,9 +82,14 @@ class PolicyEngine:
|
||||||
def _match_deny_classes(self, request: CapabilityRequest) -> str | None:
|
def _match_deny_classes(self, request: CapabilityRequest) -> str | None:
|
||||||
spend_prefixes = tuple(self.deny_classes.get("spend", {}).get("match_prefixes", []))
|
spend_prefixes = tuple(self.deny_classes.get("spend", {}).get("match_prefixes", []))
|
||||||
volume_tags = tuple(self.deny_classes.get("volume_cost", {}).get("match_tags", []))
|
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:
|
for prefix in spend_prefixes:
|
||||||
lowered_prefix = prefix.lower()
|
lowered_prefix = prefix.lower()
|
||||||
if any(token.startswith(lowered_prefix) for token in lowered_tokens):
|
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):
|
if any(str(field).lower() in credential_fields for field in requested_fields):
|
||||||
return "credential_exfil"
|
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"
|
return "credential_exfil"
|
||||||
|
|
||||||
amount_keys = {"amount", "amount_cents", "amount_eur"}
|
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 "spend"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@ import json
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Protocol
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -101,7 +100,9 @@ class QontoClient:
|
||||||
if attempt < self.max_retries:
|
if attempt < self.max_retries:
|
||||||
attempt += 1
|
attempt += 1
|
||||||
continue
|
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:
|
except httpx.TransportError as exc:
|
||||||
if attempt < self.max_retries:
|
if attempt < self.max_retries:
|
||||||
attempt += 1
|
attempt += 1
|
||||||
|
|
@ -179,7 +180,9 @@ class FixtureQontoClient:
|
||||||
status_code=500,
|
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:
|
if iban:
|
||||||
filtered = [item for item in filtered if item.get("iban") in {None, "", iban}]
|
filtered = [item for item in filtered if item.get("iban") in {None, "", iban}]
|
||||||
if status:
|
if status:
|
||||||
|
|
@ -221,14 +224,18 @@ class FixtureQontoClient:
|
||||||
)
|
)
|
||||||
return payload
|
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 = []
|
dated = []
|
||||||
for transaction in transactions:
|
for transaction in transactions:
|
||||||
settled_at = transaction.get("settled_at") or transaction.get("settledAt")
|
settled_at = transaction.get("settled_at") or transaction.get("settledAt")
|
||||||
if not settled_at:
|
if not settled_at:
|
||||||
dated.append((None, transaction))
|
dated.append((None, transaction))
|
||||||
continue
|
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]
|
dates = [item[0] for item in dated if item[0] is not None]
|
||||||
if not dates:
|
if not dates:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,13 @@ from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from qonto_assistant.audit import AuditLogger, utc_now_iso
|
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.errors import InvalidRequestError, PolicyDeniedError, UpstreamError
|
||||||
from qonto_assistant.live_authorization import LiveAuthorizationGate
|
from qonto_assistant.live_authorization import LiveAuthorizationGate
|
||||||
from qonto_assistant.policy import PolicyEngine
|
from qonto_assistant.policy import PolicyEngine
|
||||||
|
|
@ -102,7 +108,9 @@ class CapabilityService:
|
||||||
resource_scope="snapshot",
|
resource_scope="snapshot",
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
protocol=protocol,
|
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(
|
def get_cost_run_rate_hints(
|
||||||
|
|
@ -227,13 +235,17 @@ class CapabilityService:
|
||||||
def _build_accounts_payload(self, _: Mapping[str, Any]) -> dict[str, Any]:
|
def _build_accounts_payload(self, _: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
organization_payload = self.client.get_organization()
|
organization_payload = self.client.get_organization()
|
||||||
organization = _extract_organization(organization_payload)
|
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 {
|
return {
|
||||||
"organization": _normalize_organization(organization),
|
"organization": _normalize_organization(organization),
|
||||||
"accounts": accounts,
|
"accounts": accounts,
|
||||||
"totals": {
|
"totals": {
|
||||||
"balance": round(sum(account["balance"] for account in accounts), 2),
|
"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,
|
status=status,
|
||||||
side=side,
|
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 {
|
return {
|
||||||
"organization": _normalize_organization(organization),
|
"organization": _normalize_organization(organization),
|
||||||
"account": _normalize_account(selected_account),
|
"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]:
|
def _build_snapshot_payload(self, *, window_days: int, page_size: int) -> dict[str, Any]:
|
||||||
accounts_payload = self._build_accounts_payload({})
|
accounts_payload = self._build_accounts_payload({})
|
||||||
accounts = accounts_payload["accounts"]
|
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 = []
|
recent_transactions = []
|
||||||
if main_account is not None:
|
if main_account is not None:
|
||||||
transactions_payload = self._build_transactions_payload(
|
transactions_payload = self._build_transactions_payload(
|
||||||
|
|
@ -297,10 +313,14 @@ class CapabilityService:
|
||||||
"recent_transactions": recent_transactions[:10],
|
"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_payload = self._build_accounts_payload({})
|
||||||
accounts = accounts_payload["accounts"]
|
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]] = []
|
recent_transactions: list[dict[str, Any]] = []
|
||||||
if main_account is not None:
|
if main_account is not None:
|
||||||
transactions_payload = self._build_transactions_payload(
|
transactions_payload = self._build_transactions_payload(
|
||||||
|
|
@ -343,6 +363,8 @@ class CapabilityService:
|
||||||
deny_reason=deny_reason,
|
deny_reason=deny_reason,
|
||||||
policy_version=self.policy.version,
|
policy_version=self.policy.version,
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
|
identity_binding=claims.identity_binding,
|
||||||
|
egress_destination="qonto-thirdparty-api",
|
||||||
qonto_http_status=qonto_http_status,
|
qonto_http_status=qonto_http_status,
|
||||||
result_count=result_count,
|
result_count=result_count,
|
||||||
)
|
)
|
||||||
|
|
@ -429,7 +451,9 @@ def _amount_value(raw: Mapping[str, Any], key: str = "balance") -> float:
|
||||||
return round(float(amount), 2)
|
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:
|
if not accounts:
|
||||||
raise UpstreamError(
|
raise UpstreamError(
|
||||||
"No accounts available in organization payload",
|
"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:
|
for account in accounts:
|
||||||
if account.get("slug") == account_slug:
|
if account.get("slug") == account_slug:
|
||||||
return account
|
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:
|
for account in accounts:
|
||||||
if account.get("main"):
|
if account.get("main"):
|
||||||
return account
|
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"]))
|
recurring_debits.sort(key=lambda item: (-item["amount"], item["label"]))
|
||||||
total_debits = round(
|
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,
|
2,
|
||||||
)
|
)
|
||||||
total_credits = round(
|
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,
|
2,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from qonto_assistant.app import create_app
|
||||||
from qonto_assistant.audit import AuditLogger
|
from qonto_assistant.audit import AuditLogger
|
||||||
from qonto_assistant.config import Settings
|
from qonto_assistant.config import Settings
|
||||||
from qonto_assistant.contracts import ActorClaims
|
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.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||||
from qonto_assistant.service import CapabilityService
|
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:
|
def _settings() -> Settings:
|
||||||
|
|
@ -38,6 +42,7 @@ def _settings() -> Settings:
|
||||||
deny_escalation_threshold=3,
|
deny_escalation_threshold=3,
|
||||||
deny_escalation_window_seconds=60,
|
deny_escalation_window_seconds=60,
|
||||||
deny_escalation_lockout_seconds=300,
|
deny_escalation_lockout_seconds=300,
|
||||||
|
audit_heartbeat_interval_seconds=86400,
|
||||||
key_cape_jwks_url=None,
|
key_cape_jwks_url=None,
|
||||||
key_cape_issuer="https://key-cape.netkingdom",
|
key_cape_issuer="https://key-cape.netkingdom",
|
||||||
key_cape_audience="qonto-assistant",
|
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["summary"]["total_balance"] == 2185.94
|
||||||
assert payload["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31"
|
assert payload["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31"
|
||||||
assert any(event["capability"] == "snapshot_bundle" for event in events)
|
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
|
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:
|
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 payload["nested"]["token"] == REDACTED
|
||||||
assert "super-secret" not in str(events[0])
|
assert "super-secret" not in str(events[0])
|
||||||
assert "top-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
|
from qonto_assistant.service import CapabilityService
|
||||||
|
|
||||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
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
|
# Fields whose values are expected to vary per call (timing/identifiers) or
|
||||||
# by design (protocol). Everything else must match exactly between REST and
|
# by design (protocol). Everything else must match exactly between REST and
|
||||||
# MCP for the same logical call.
|
# MCP for the same logical call.
|
||||||
NON_COMPARABLE_FIELDS = {"request_id", "timestamp", "latency_ms", "protocol"}
|
NON_COMPARABLE_FIELDS = {
|
||||||
SECRET_FIELD_NAMES = {"api_key", "authorization", "authorization_header", "openbao_token", "secret", "token"}
|
"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]]]:
|
def _service() -> tuple[CapabilityService, list[dict[str, object]]]:
|
||||||
events: 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(
|
service = CapabilityService(
|
||||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||||
policy=policy,
|
policy=policy,
|
||||||
|
|
@ -56,10 +73,14 @@ def test_allow_path_audit_schema_identical_across_protocols() -> None:
|
||||||
assert rest_event["protocol"] == "rest"
|
assert rest_event["protocol"] == "rest"
|
||||||
assert mcp_event["protocol"] == "mcp"
|
assert mcp_event["protocol"] == "mcp"
|
||||||
for field in set(rest_event) - NON_COMPARABLE_FIELDS:
|
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["decision"] == "allow"
|
||||||
assert rest_event["capability"] == "org_summary"
|
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:
|
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
|
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"
|
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:
|
def test_tracker_allows_denies_below_threshold() -> None:
|
||||||
clock = _FakeClock()
|
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")
|
||||||
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:
|
def test_tracker_locks_out_after_threshold_within_window() -> None:
|
||||||
clock = _FakeClock()
|
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):
|
for _ in range(3):
|
||||||
tracker.record_deny("binky:prober", "arg_constraint")
|
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:
|
def test_tracker_ignores_non_escalating_deny_reasons() -> None:
|
||||||
clock = _FakeClock()
|
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):
|
for _ in range(10):
|
||||||
tracker.record_deny("binky:prober", "tenant_scope")
|
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:
|
def test_tracker_denies_outside_window_do_not_accumulate() -> None:
|
||||||
clock = _FakeClock()
|
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")
|
||||||
clock.advance(61)
|
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:
|
def test_tracker_lockout_expires_after_lockout_window() -> None:
|
||||||
clock = _FakeClock()
|
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):
|
for _ in range(3):
|
||||||
tracker.record_deny("binky:prober", "arg_constraint")
|
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:
|
def test_tracker_is_scoped_per_actor() -> None:
|
||||||
clock = _FakeClock()
|
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):
|
for _ in range(3):
|
||||||
tracker.record_deny("binky:prober", "arg_constraint")
|
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:
|
def test_service_locks_out_actor_after_repeated_arg_constraint_denies() -> None:
|
||||||
clock = _FakeClock()
|
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]] = []
|
events: list[dict[str, object]] = []
|
||||||
service = _service(tracker, events)
|
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:
|
def test_service_does_not_lock_out_for_ordinary_denies() -> None:
|
||||||
clock = _FakeClock()
|
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]] = []
|
events: list[dict[str, object]] = []
|
||||||
service = _service(tracker, events)
|
service = _service(tracker, events)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,10 @@ def _client(handler) -> FlexAuthCheckClient:
|
||||||
|
|
||||||
def test_allow_effect_authorizes() -> None:
|
def test_allow_effect_authorizes() -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
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
|
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"])
|
@pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"])
|
||||||
def test_non_allow_effects_deny(effect: str) -> None:
|
def test_non_allow_effects_deny(effect: str) -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
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
|
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:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
seen.update(json.loads(request.content))
|
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())
|
_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})
|
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(
|
return KeyCapeTokenVerifier(
|
||||||
jwks_url="https://key-cape.netkingdom.test/jwks",
|
jwks_url="https://key-cape.netkingdom.test/jwks",
|
||||||
issuer=ISSUER,
|
issuer=ISSUER,
|
||||||
|
|
@ -71,6 +73,7 @@ def test_verify_accepts_valid_token() -> None:
|
||||||
|
|
||||||
assert claims.actor_id == "agent-harness-binky"
|
assert claims.actor_id == "agent-harness-binky"
|
||||||
assert claims.tenant_id == "tenant:friendly:binky"
|
assert claims.tenant_id == "tenant:friendly:binky"
|
||||||
|
assert claims.identity_binding == "key_cape_jwt"
|
||||||
assert claims.lane == "blue"
|
assert claims.lane == "blue"
|
||||||
assert "finance.qonto.read" in claims.scopes
|
assert "finance.qonto.read" in claims.scopes
|
||||||
|
|
||||||
|
|
@ -194,6 +197,7 @@ def _settings() -> Settings:
|
||||||
deny_escalation_threshold=3,
|
deny_escalation_threshold=3,
|
||||||
deny_escalation_window_seconds=60,
|
deny_escalation_window_seconds=60,
|
||||||
deny_escalation_lockout_seconds=300,
|
deny_escalation_lockout_seconds=300,
|
||||||
|
audit_heartbeat_interval_seconds=86400,
|
||||||
key_cape_jwks_url=None,
|
key_cape_jwks_url=None,
|
||||||
key_cape_issuer=ISSUER,
|
key_cape_issuer=ISSUER,
|
||||||
key_cape_audience=AUDIENCE,
|
key_cape_audience=AUDIENCE,
|
||||||
|
|
@ -236,7 +240,9 @@ def test_required_verifier_rejects_missing_bearer_token() -> None:
|
||||||
verifier = _verifier(jwk, required=True)
|
verifier = _verifier(jwk, required=True)
|
||||||
|
|
||||||
with pytest.raises(KeyCapeAuthError):
|
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:
|
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.actor_id == "someone"
|
||||||
assert claims.tenant_id == "binky"
|
assert claims.tenant_id == "binky"
|
||||||
|
assert claims.identity_binding == "self_asserted"
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_bearer_token_is_rejected_even_when_not_required() -> None:
|
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.contracts import ActorClaims
|
||||||
from qonto_assistant.errors import PolicyDeniedError
|
from qonto_assistant.errors import PolicyDeniedError
|
||||||
from qonto_assistant.flex_auth_client import FlexAuthCheckClient
|
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.policy import PolicyEngine
|
||||||
from qonto_assistant.qonto_client import FixtureQontoClient
|
from qonto_assistant.qonto_client import FixtureQontoClient
|
||||||
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||||
from qonto_assistant.service import CapabilityService
|
from qonto_assistant.service import CapabilityService
|
||||||
from qonto_assistant.tenant_engine_client import TenantEngineClient
|
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"
|
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 _flex_auth_client(effect: str) -> FlexAuthCheckClient:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
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 _tenant_engine_client(roles: list[str]) -> TenantEngineClient:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
return httpx.Response(200, json={"tenant_id": "tenant:friendly:binky", "roles": roles})
|
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 -------------------------------------------------------------------
|
# --- Gate unit tests -------------------------------------------------------------------
|
||||||
|
|
@ -95,7 +108,9 @@ def test_gate_denies_when_tenant_engine_unreachable() -> None:
|
||||||
|
|
||||||
gate = LiveAuthorizationGate(
|
gate = LiveAuthorizationGate(
|
||||||
flex_auth_client=_flex_auth_client("allow"),
|
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"}),
|
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:
|
def _service(gate: LiveAuthorizationGate, events: list[dict[str, object]]) -> CapabilityService:
|
||||||
return CapabilityService(
|
return CapabilityService(
|
||||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
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),
|
audit_logger=AuditLogger(sink=events.append),
|
||||||
rate_limiter=RateLimiter(limit=20, window_seconds=60),
|
rate_limiter=RateLimiter(limit=20, window_seconds=60),
|
||||||
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||||
|
|
@ -151,7 +168,9 @@ def test_service_without_gate_configured_behaves_as_before() -> None:
|
||||||
events: list[dict[str, object]] = []
|
events: list[dict[str, object]] = []
|
||||||
service = CapabilityService(
|
service = CapabilityService(
|
||||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
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),
|
audit_logger=AuditLogger(sink=events.append),
|
||||||
rate_limiter=RateLimiter(limit=20, window_seconds=60),
|
rate_limiter=RateLimiter(limit=20, window_seconds=60),
|
||||||
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||||
from qonto_assistant.service import CapabilityService
|
from qonto_assistant.service import CapabilityService
|
||||||
|
|
||||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
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:
|
def _settings() -> Settings:
|
||||||
|
|
@ -21,7 +23,9 @@ def _settings() -> Settings:
|
||||||
|
|
||||||
def _service() -> tuple[CapabilityService, list[dict[str, object]]]:
|
def _service() -> tuple[CapabilityService, list[dict[str, object]]]:
|
||||||
events: 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(
|
service = CapabilityService(
|
||||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||||
policy=policy,
|
policy=policy,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import pytest
|
||||||
from qonto_assistant.contracts import ActorClaims, CapabilityRequest
|
from qonto_assistant.contracts import ActorClaims, CapabilityRequest
|
||||||
from qonto_assistant.policy import PolicyEngine
|
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:
|
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:
|
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.allowed is False
|
||||||
assert decision.reason == "arg_constraint"
|
assert decision.reason == "arg_constraint"
|
||||||
|
|
||||||
|
|
||||||
def test_policy_denies_volume_cost_shaped_requests() -> None:
|
def test_policy_denies_volume_cost_shaped_requests() -> None:
|
||||||
decision = _policy().decide(
|
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.allowed is False
|
||||||
assert decision.reason == "volume_cost"
|
assert decision.reason == "volume_cost"
|
||||||
|
|
||||||
|
|
||||||
def test_policy_denies_credential_exfiltration_flags() -> None:
|
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.allowed is False
|
||||||
assert decision.reason == "credential_exfil"
|
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.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||||
from qonto_assistant.service import CapabilityService
|
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"
|
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 test_active_roles_returns_roles_on_200() -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
assert request.url.path == "/tenants/tenant:friendly:binky/roles/live"
|
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")
|
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