feat(audit): publish sequenced heartbeat and reconciliation evidence
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ec5-7e2b-7743-ac08-719e1b0f42e2
This commit is contained in:
parent
fce60099c4
commit
b9349782f4
32 changed files with 839 additions and 111 deletions
|
|
@ -1,7 +1,9 @@
|
|||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from qonto_assistant.app import create_app
|
||||
from qonto_assistant.audit import AuditLogger
|
||||
from qonto_assistant.config import Settings
|
||||
from qonto_assistant.contracts import ActorClaims
|
||||
|
|
@ -12,7 +14,9 @@ from qonto_assistant.qonto_client import QontoClient
|
|||
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
|
|
@ -38,6 +42,7 @@ def _settings() -> Settings:
|
|||
deny_escalation_threshold=3,
|
||||
deny_escalation_window_seconds=60,
|
||||
deny_escalation_lockout_seconds=300,
|
||||
audit_heartbeat_interval_seconds=86400,
|
||||
key_cape_jwks_url=None,
|
||||
key_cape_issuer="https://key-cape.netkingdom",
|
||||
key_cape_audience="qonto-assistant",
|
||||
|
|
@ -227,3 +232,56 @@ def test_snapshot_contract_returns_cost_run_rate_hints_for_90_day_window(monkeyp
|
|||
assert payload["summary"]["total_balance"] == 2185.94
|
||||
assert payload["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31"
|
||||
assert any(event["capability"] == "snapshot_bundle" for event in events)
|
||||
|
||||
|
||||
async def test_app_lifecycle_and_reconciliation_use_the_request_audit_stream(monkeypatch) -> None:
|
||||
service, events = _service(monkeypatch)
|
||||
app = create_app(settings=_settings(), service=service)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
assert events[-1]["event_class"] == "audit.heartbeat"
|
||||
assert events[-1]["reason"] == "startup"
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
response = await client.get(
|
||||
"/v1/audit/reconciliation",
|
||||
headers={"X-Actor-ID": "observer", "X-Tenant-ID": "binky"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["last_stream_sequence"] == 1
|
||||
assert response.json()["source_transition_counts"] == {
|
||||
"audit.allow": 0,
|
||||
"audit.deny": 0,
|
||||
}
|
||||
assert len(events) == 1
|
||||
|
||||
assert events[-1]["event_class"] == "audit.heartbeat"
|
||||
assert events[-1]["reason"] == "shutdown"
|
||||
assert events[-1]["stream_sequence"] == 2
|
||||
|
||||
|
||||
async def test_client_closes_when_shutdown_heartbeat_fails(monkeypatch) -> None:
|
||||
service, _ = _service(monkeypatch)
|
||||
closed = []
|
||||
monkeypatch.setattr(service.client, "close", lambda: closed.append(True))
|
||||
|
||||
def failing_shutdown(payload):
|
||||
if payload.get("reason") == "shutdown":
|
||||
raise RuntimeError("sink unavailable")
|
||||
|
||||
service.audit_logger.sink = failing_shutdown
|
||||
app = create_app(settings=_settings(), service=service)
|
||||
with pytest.raises(ExceptionGroup) as caught:
|
||||
async with app.router.lifespan_context(app):
|
||||
pass
|
||||
assert caught.group_contains(RuntimeError, match="sink unavailable")
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_app_rejects_split_audit_streams(monkeypatch) -> None:
|
||||
service, _ = _service(monkeypatch)
|
||||
with pytest.raises(ValueError, match="share one audit_logger"):
|
||||
create_app(settings=_settings(), service=service, audit_logger=AuditLogger())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import suppress
|
||||
|
||||
from qonto_assistant.audit import AuditLogger, REDACTED
|
||||
from qonto_assistant.app import _emit_audit_heartbeats
|
||||
from qonto_assistant.audit import AUDIT_STREAM_ID, REDACTED, AuditLogger
|
||||
|
||||
|
||||
def test_audit_logger_redacts_secret_fields() -> None:
|
||||
|
|
@ -23,3 +27,115 @@ def test_audit_logger_redacts_secret_fields() -> None:
|
|||
assert payload["nested"]["token"] == REDACTED
|
||||
assert "super-secret" not in str(events[0])
|
||||
assert "top-secret" not in str(events[0])
|
||||
|
||||
|
||||
def test_audit_logger_sequences_events_and_counts_source_transitions() -> None:
|
||||
events: list[dict[str, object]] = []
|
||||
audit = AuditLogger(sink=events.append, instance_id="instance-1")
|
||||
|
||||
allowed = audit.emit({"decision": "allow", "request_id": "allow-1"})
|
||||
denied = audit.emit({"decision": "deny", "request_id": "deny-1"})
|
||||
snapshot = audit.reconciliation_snapshot()
|
||||
|
||||
assert allowed["event_class"] == "audit.allow"
|
||||
assert denied["event_class"] == "audit.deny"
|
||||
assert [event["stream_sequence"] for event in events] == [1, 2]
|
||||
assert all(event["stream_id"] == AUDIT_STREAM_ID for event in events)
|
||||
assert all(event["stream_instance_id"] == "instance-1" for event in events)
|
||||
assert snapshot["last_stream_sequence"] == 2
|
||||
assert snapshot["source_transition_counts"] == {"audit.allow": 1, "audit.deny": 1}
|
||||
assert snapshot["since_last_heartbeat_counts"] == {"audit.allow": 1, "audit.deny": 1}
|
||||
|
||||
|
||||
def test_heartbeat_reconciles_window_and_resets_only_window_counts() -> None:
|
||||
timestamps = iter(
|
||||
[
|
||||
"2026-09-04T00:00:00+00:00",
|
||||
"2026-09-04T00:01:00+00:00",
|
||||
"2026-09-04T00:02:00+00:00",
|
||||
"2026-09-04T00:03:00+00:00",
|
||||
]
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
audit = AuditLogger(
|
||||
sink=events.append, instance_id="instance-1", clock=lambda: next(timestamps)
|
||||
)
|
||||
|
||||
first = audit.emit_heartbeat(reason="startup")
|
||||
audit.emit({"decision": "deny", "request_id": "deny-1"})
|
||||
second = audit.emit_heartbeat(reason="periodic")
|
||||
snapshot = audit.reconciliation_snapshot()
|
||||
|
||||
assert first["assertion"] == "nothing-to-report"
|
||||
assert first["window_transition_counts"] == {"audit.allow": 0, "audit.deny": 0}
|
||||
assert second["assertion"] == "transitions-reported"
|
||||
assert second["source_transition_counts"] == {"audit.allow": 0, "audit.deny": 1}
|
||||
assert second["window_transition_counts"] == {"audit.allow": 0, "audit.deny": 1}
|
||||
assert second["stream_sequence"] == 3
|
||||
assert snapshot["source_transition_counts"] == {"audit.allow": 0, "audit.deny": 1}
|
||||
assert snapshot["since_last_heartbeat_counts"] == {"audit.allow": 0, "audit.deny": 0}
|
||||
|
||||
|
||||
def test_primary_log_is_written_before_optional_sink_failure(caplog) -> None:
|
||||
def broken_sink(_: dict[str, object]) -> None:
|
||||
raise RuntimeError("secondary sink unavailable")
|
||||
|
||||
audit = AuditLogger(sink=broken_sink, instance_id="instance-1")
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="qonto_assistant.audit"):
|
||||
try:
|
||||
audit.emit({"decision": "deny", "request_id": "deny-1"})
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("Expected the secondary sink error")
|
||||
|
||||
assert '"event_class": "audit.deny"' in caplog.text
|
||||
assert '"stream_sequence": 1' in caplog.text
|
||||
|
||||
|
||||
async def test_periodic_heartbeat_loop_emits_until_cancelled() -> None:
|
||||
events: list[dict[str, object]] = []
|
||||
audit = AuditLogger(sink=events.append, instance_id="instance-1")
|
||||
ready = asyncio.Event()
|
||||
|
||||
def collect(payload):
|
||||
events.append(payload)
|
||||
if len(events) >= 2:
|
||||
ready.set()
|
||||
|
||||
audit.sink = collect
|
||||
task = asyncio.create_task(_emit_audit_heartbeats(audit_logger=audit, interval_seconds=0.01))
|
||||
try:
|
||||
await asyncio.wait_for(ready.wait(), timeout=2)
|
||||
finally:
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert len(events) >= 2
|
||||
assert all(event["event_class"] == "audit.heartbeat" for event in events)
|
||||
assert all(event["reason"] == "periodic" for event in events)
|
||||
|
||||
|
||||
def test_concurrent_transitions_and_heartbeats_preserve_publication_order() -> None:
|
||||
events = []
|
||||
audit = AuditLogger(sink=events.append)
|
||||
|
||||
def publish(index):
|
||||
if index % 3 == 0:
|
||||
audit.emit_heartbeat()
|
||||
else:
|
||||
audit.emit({"decision": "deny"})
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
list(executor.map(publish, range(120)))
|
||||
|
||||
assert [event["stream_sequence"] for event in events] == list(range(1, 121))
|
||||
denies = 0
|
||||
for event in events:
|
||||
if event["event_class"] == "audit.deny":
|
||||
denies += 1
|
||||
else:
|
||||
assert event["source_transition_counts"]["audit.deny"] == denies
|
||||
assert audit.reconciliation_snapshot()["source_transition_counts"]["audit.deny"] == 80
|
||||
|
|
|
|||
|
|
@ -18,18 +18,35 @@ from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
|||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
|
||||
# Fields whose values are expected to vary per call (timing/identifiers) or
|
||||
# by design (protocol). Everything else must match exactly between REST and
|
||||
# MCP for the same logical call.
|
||||
NON_COMPARABLE_FIELDS = {"request_id", "timestamp", "latency_ms", "protocol"}
|
||||
SECRET_FIELD_NAMES = {"api_key", "authorization", "authorization_header", "openbao_token", "secret", "token"}
|
||||
NON_COMPARABLE_FIELDS = {
|
||||
"request_id",
|
||||
"timestamp",
|
||||
"latency_ms",
|
||||
"protocol",
|
||||
"stream_sequence",
|
||||
}
|
||||
SECRET_FIELD_NAMES = {
|
||||
"api_key",
|
||||
"authorization",
|
||||
"authorization_header",
|
||||
"openbao_token",
|
||||
"secret",
|
||||
"token",
|
||||
}
|
||||
|
||||
|
||||
def _service() -> tuple[CapabilityService, list[dict[str, object]]]:
|
||||
events: list[dict[str, object]] = []
|
||||
policy = PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False)
|
||||
policy = PolicyEngine.from_file(
|
||||
POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False
|
||||
)
|
||||
service = CapabilityService(
|
||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||
policy=policy,
|
||||
|
|
@ -56,10 +73,14 @@ def test_allow_path_audit_schema_identical_across_protocols() -> None:
|
|||
assert rest_event["protocol"] == "rest"
|
||||
assert mcp_event["protocol"] == "mcp"
|
||||
for field in set(rest_event) - NON_COMPARABLE_FIELDS:
|
||||
assert rest_event[field] == mcp_event[field], f"field {field!r} diverged: {rest_event[field]!r} != {mcp_event[field]!r}"
|
||||
assert rest_event[field] == mcp_event[field], (
|
||||
f"field {field!r} diverged: {rest_event[field]!r} != {mcp_event[field]!r}"
|
||||
)
|
||||
|
||||
assert rest_event["decision"] == "allow"
|
||||
assert rest_event["capability"] == "org_summary"
|
||||
assert rest_event["identity_binding"] == "self_asserted"
|
||||
assert rest_event["egress_destination"] == "qonto-thirdparty-api"
|
||||
|
||||
|
||||
def test_deny_path_audit_schema_identical_across_protocols() -> None:
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ from qonto_assistant.security_watch import (
|
|||
)
|
||||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
|
||||
|
||||
|
|
@ -53,7 +55,9 @@ def _service(tracker: DenyEscalationTracker, events: list[dict[str, object]]) ->
|
|||
|
||||
def test_tracker_allows_denies_below_threshold() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=3, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
|
@ -63,7 +67,9 @@ def test_tracker_allows_denies_below_threshold() -> None:
|
|||
|
||||
def test_tracker_locks_out_after_threshold_within_window() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=3, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
|
||||
for _ in range(3):
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
|
@ -74,7 +80,9 @@ def test_tracker_locks_out_after_threshold_within_window() -> None:
|
|||
|
||||
def test_tracker_ignores_non_escalating_deny_reasons() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=3, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
|
||||
for _ in range(10):
|
||||
tracker.record_deny("binky:prober", "tenant_scope")
|
||||
|
|
@ -85,7 +93,9 @@ def test_tracker_ignores_non_escalating_deny_reasons() -> None:
|
|||
|
||||
def test_tracker_denies_outside_window_do_not_accumulate() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=3, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
clock.advance(61)
|
||||
|
|
@ -98,7 +108,9 @@ def test_tracker_denies_outside_window_do_not_accumulate() -> None:
|
|||
|
||||
def test_tracker_lockout_expires_after_lockout_window() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=3, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
|
||||
for _ in range(3):
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
|
@ -118,7 +130,9 @@ def test_tracker_lockout_expires_after_lockout_window() -> None:
|
|||
|
||||
def test_tracker_is_scoped_per_actor() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=3, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=3, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
|
||||
for _ in range(3):
|
||||
tracker.record_deny("binky:prober", "arg_constraint")
|
||||
|
|
@ -131,7 +145,9 @@ def test_tracker_is_scoped_per_actor() -> None:
|
|||
|
||||
def test_service_locks_out_actor_after_repeated_arg_constraint_denies() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=2, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=2, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
service = _service(tracker, events)
|
||||
|
||||
|
|
@ -164,7 +180,9 @@ def test_service_locks_out_actor_after_repeated_arg_constraint_denies() -> None:
|
|||
|
||||
def test_service_does_not_lock_out_for_ordinary_denies() -> None:
|
||||
clock = _FakeClock()
|
||||
tracker = DenyEscalationTracker(threshold=2, window_seconds=60, lockout_seconds=300, clock=clock)
|
||||
tracker = DenyEscalationTracker(
|
||||
threshold=2, window_seconds=60, lockout_seconds=300, clock=clock
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
service = _service(tracker, events)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ def _client(handler) -> FlexAuthCheckClient:
|
|||
|
||||
def test_allow_effect_authorizes() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
|
||||
)
|
||||
|
||||
assert _client(handler).is_allowed(_request()) is True
|
||||
|
||||
|
|
@ -33,7 +36,10 @@ def test_allow_effect_authorizes() -> None:
|
|||
@pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"])
|
||||
def test_non_allow_effects_deny(effect: str) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}},
|
||||
)
|
||||
|
||||
assert _client(handler).is_allowed(_request()) is False
|
||||
|
||||
|
|
@ -78,7 +84,10 @@ def test_request_body_matches_schema_shape() -> None:
|
|||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.update(json.loads(request.content))
|
||||
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
|
||||
)
|
||||
|
||||
_client(handler).is_allowed(_request())
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ def _token(private_key: rsa.RSAPrivateKey, *, kid: str = KID, **claim_overrides)
|
|||
return jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": kid})
|
||||
|
||||
|
||||
def _verifier(jwk: dict, *, required: bool = False, calls: list[int] | None = None) -> KeyCapeTokenVerifier:
|
||||
def _verifier(
|
||||
jwk: dict, *, required: bool = False, calls: list[int] | None = None
|
||||
) -> KeyCapeTokenVerifier:
|
||||
return KeyCapeTokenVerifier(
|
||||
jwks_url="https://key-cape.netkingdom.test/jwks",
|
||||
issuer=ISSUER,
|
||||
|
|
@ -71,6 +73,7 @@ def test_verify_accepts_valid_token() -> None:
|
|||
|
||||
assert claims.actor_id == "agent-harness-binky"
|
||||
assert claims.tenant_id == "tenant:friendly:binky"
|
||||
assert claims.identity_binding == "key_cape_jwt"
|
||||
assert claims.lane == "blue"
|
||||
assert "finance.qonto.read" in claims.scopes
|
||||
|
||||
|
|
@ -194,6 +197,7 @@ def _settings() -> Settings:
|
|||
deny_escalation_threshold=3,
|
||||
deny_escalation_window_seconds=60,
|
||||
deny_escalation_lockout_seconds=300,
|
||||
audit_heartbeat_interval_seconds=86400,
|
||||
key_cape_jwks_url=None,
|
||||
key_cape_issuer=ISSUER,
|
||||
key_cape_audience=AUDIENCE,
|
||||
|
|
@ -236,7 +240,9 @@ def test_required_verifier_rejects_missing_bearer_token() -> None:
|
|||
verifier = _verifier(jwk, required=True)
|
||||
|
||||
with pytest.raises(KeyCapeAuthError):
|
||||
actor_claims_from_headers({"x-actor-id": "someone"}, _settings(), key_cape_verifier=verifier)
|
||||
actor_claims_from_headers(
|
||||
{"x-actor-id": "someone"}, _settings(), key_cape_verifier=verifier
|
||||
)
|
||||
|
||||
|
||||
def test_optional_verifier_falls_back_to_self_asserted_headers_when_absent() -> None:
|
||||
|
|
@ -249,6 +255,7 @@ def test_optional_verifier_falls_back_to_self_asserted_headers_when_absent() ->
|
|||
|
||||
assert claims.actor_id == "someone"
|
||||
assert claims.tenant_id == "binky"
|
||||
assert claims.identity_binding == "self_asserted"
|
||||
|
||||
|
||||
def test_invalid_bearer_token_is_rejected_even_when_not_required() -> None:
|
||||
|
|
|
|||
|
|
@ -7,14 +7,20 @@ from qonto_assistant.audit import AuditLogger
|
|||
from qonto_assistant.contracts import ActorClaims
|
||||
from qonto_assistant.errors import PolicyDeniedError
|
||||
from qonto_assistant.flex_auth_client import FlexAuthCheckClient
|
||||
from qonto_assistant.live_authorization import DENY_LIVE_AUTHZ, DENY_TENANT_ROLE, LiveAuthorizationGate
|
||||
from qonto_assistant.live_authorization import (
|
||||
DENY_LIVE_AUTHZ,
|
||||
DENY_TENANT_ROLE,
|
||||
LiveAuthorizationGate,
|
||||
)
|
||||
from qonto_assistant.policy import PolicyEngine
|
||||
from qonto_assistant.qonto_client import FixtureQontoClient
|
||||
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||
from qonto_assistant.service import CapabilityService
|
||||
from qonto_assistant.tenant_engine_client import TenantEngineClient
|
||||
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
|
||||
|
||||
|
|
@ -24,16 +30,23 @@ def _claims(tenant_id: str = "tenant:friendly:binky") -> ActorClaims:
|
|||
|
||||
def _flex_auth_client(effect: str) -> FlexAuthCheckClient:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}},
|
||||
)
|
||||
|
||||
return FlexAuthCheckClient(base_url="https://flex-auth.test", transport=httpx.MockTransport(handler))
|
||||
return FlexAuthCheckClient(
|
||||
base_url="https://flex-auth.test", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
|
||||
def _tenant_engine_client(roles: list[str]) -> TenantEngineClient:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"tenant_id": "tenant:friendly:binky", "roles": roles})
|
||||
|
||||
return TenantEngineClient(base_url="https://tenant-engine.test", transport=httpx.MockTransport(handler))
|
||||
return TenantEngineClient(
|
||||
base_url="https://tenant-engine.test", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
|
||||
# --- Gate unit tests -------------------------------------------------------------------
|
||||
|
|
@ -95,7 +108,9 @@ def test_gate_denies_when_tenant_engine_unreachable() -> None:
|
|||
|
||||
gate = LiveAuthorizationGate(
|
||||
flex_auth_client=_flex_auth_client("allow"),
|
||||
tenant_engine_client=TenantEngineClient(base_url="https://tenant-engine.test", transport=httpx.MockTransport(handler)),
|
||||
tenant_engine_client=TenantEngineClient(
|
||||
base_url="https://tenant-engine.test", transport=httpx.MockTransport(handler)
|
||||
),
|
||||
required_tenant_roles=frozenset({"VEN"}),
|
||||
)
|
||||
|
||||
|
|
@ -108,7 +123,9 @@ def test_gate_denies_when_tenant_engine_unreachable() -> None:
|
|||
def _service(gate: LiveAuthorizationGate, events: list[dict[str, object]]) -> CapabilityService:
|
||||
return CapabilityService(
|
||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||
policy=PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False),
|
||||
policy=PolicyEngine.from_file(
|
||||
POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False
|
||||
),
|
||||
audit_logger=AuditLogger(sink=events.append),
|
||||
rate_limiter=RateLimiter(limit=20, window_seconds=60),
|
||||
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||
|
|
@ -151,7 +168,9 @@ def test_service_without_gate_configured_behaves_as_before() -> None:
|
|||
events: list[dict[str, object]] = []
|
||||
service = CapabilityService(
|
||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||
policy=PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False),
|
||||
policy=PolicyEngine.from_file(
|
||||
POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False
|
||||
),
|
||||
audit_logger=AuditLogger(sink=events.append),
|
||||
rate_limiter=RateLimiter(limit=20, window_seconds=60),
|
||||
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
|||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
|
|
@ -21,7 +23,9 @@ def _settings() -> Settings:
|
|||
|
||||
def _service() -> tuple[CapabilityService, list[dict[str, object]]]:
|
||||
events: list[dict[str, object]] = []
|
||||
policy = PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False)
|
||||
policy = PolicyEngine.from_file(
|
||||
POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False
|
||||
)
|
||||
service = CapabilityService(
|
||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||
policy=policy,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import pytest
|
|||
from qonto_assistant.contracts import ActorClaims, CapabilityRequest
|
||||
from qonto_assistant.policy import PolicyEngine
|
||||
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
|
||||
|
||||
def _policy(*, enforce_scope: bool = False) -> PolicyEngine:
|
||||
|
|
@ -55,21 +57,31 @@ def test_policy_denies_cross_tenant_requests() -> None:
|
|||
|
||||
|
||||
def test_policy_denies_excessive_page_size() -> None:
|
||||
decision = _policy().decide(_request("list_transactions", page=1, page_size=101, window_days=31))
|
||||
decision = _policy().decide(
|
||||
_request("list_transactions", page=1, page_size=101, window_days=31)
|
||||
)
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == "arg_constraint"
|
||||
|
||||
|
||||
def test_policy_denies_volume_cost_shaped_requests() -> None:
|
||||
decision = _policy().decide(
|
||||
_request("list_transactions", page=1, page_size=50, window_days=31, operation_type="card_operation")
|
||||
_request(
|
||||
"list_transactions",
|
||||
page=1,
|
||||
page_size=50,
|
||||
window_days=31,
|
||||
operation_type="card_operation",
|
||||
)
|
||||
)
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == "volume_cost"
|
||||
|
||||
|
||||
def test_policy_denies_credential_exfiltration_flags() -> None:
|
||||
decision = _policy().decide(_request("list_transactions", page=1, page_size=50, window_days=31, include_full_iban=True))
|
||||
decision = _policy().decide(
|
||||
_request("list_transactions", page=1, page_size=50, window_days=31, include_full_iban=True)
|
||||
)
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == "credential_exfil"
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ from qonto_assistant.qonto_client import FixtureQontoClient
|
|||
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
POLICY_FILE = (
|
||||
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
)
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ def _client(handler) -> TenantEngineClient:
|
|||
def test_active_roles_returns_roles_on_200() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/tenants/tenant:friendly:binky/roles/live"
|
||||
return httpx.Response(200, json={"tenant_id": "tenant:friendly:binky", "roles": ["VEN", "CUS"]})
|
||||
return httpx.Response(
|
||||
200, json={"tenant_id": "tenant:friendly:binky", "roles": ["VEN", "CUS"]}
|
||||
)
|
||||
|
||||
roles = _client(handler).active_roles("tenant:friendly:binky")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue