trustgraph/tests/unit/test_gateway/test_audit_middleware.py

199 lines
6.7 KiB
Python
Raw Normal View History

feat: add structured audit event system (#1027) Add a complete audit event pipeline that emits structured, machine- parseable events for every gateway request and IAM decision. Schema and publisher: - AuditEvent dataclass and notify-class queue (audit_events_queue) - AuditPublisher utility: fire-and-forget emission with envelope (schema_version, event_id, event_type, timestamp, producer) - New request_id and client_ip fields on IamRequest for correlation Gateway (gateway.request events): - aiohttp middleware assigns request_id, captures timing/status/sizes and emits an event after every HTTP request completes - IamAuth.authenticate annotates the request with identity - Main endpoint handlers annotate capability and workspace - request_id and client_ip forwarded to IAM on authenticate/authorise IAM service (iam.authenticate, iam.authorise, iam.management events): - Emits iam.authenticate for resolve-api-key, login, anonymous auth - Emits iam.authorise for authorise and authorise-many decisions - Emits iam.management for user/workspace/key mutations - All events include request_id for correlation with gateway events Design: events land on a pub/sub notify topic — non-persistent, per-subscriber delivery. If no audit consumer is deployed, events are silently discarded. Storage, retention, and alerting are consumer-side concerns outside this boundary. Added unit tests for the publisher and gateway middleware, unit tests for IAM audit emission, and a contract test for the AuditEvent schema. Tech spec: docs/tech-specs/audit-events.md
2026-07-06 10:47:49 +01:00
"""
Tests for the gateway audit middleware.
Verifies that gateway.request events are emitted with correct
metadata for success, error, and auth failure paths.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from trustgraph.gateway.audit import make_audit_middleware, _client_ip, _outcome_from_status
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_request(method="POST", path="/api/v1/config", headers=None,
remote="10.0.0.1"):
hdrs = headers or {}
req = make_mocked_request(method, path, headers=hdrs)
req._transport_peername = (remote, 0)
return req
# ---------------------------------------------------------------------------
# Pure helpers
# ---------------------------------------------------------------------------
class TestClientIp:
def test_uses_x_forwarded_for(self):
req = _make_request(headers={"X-Forwarded-For": "1.2.3.4, 10.0.0.1"})
assert _client_ip(req) == "1.2.3.4"
def test_falls_back_to_remote(self):
req = _make_request(remote="192.168.1.1")
assert _client_ip(req) == "192.168.1.1"
class TestOutcomeFromStatus:
def test_success_range(self):
assert _outcome_from_status(200) == "success"
assert _outcome_from_status(201) == "success"
assert _outcome_from_status(204) == "success"
assert _outcome_from_status(301) == "success"
def test_unauthenticated(self):
assert _outcome_from_status(401) == "unauthenticated"
def test_denied(self):
assert _outcome_from_status(403) == "denied"
def test_error(self):
assert _outcome_from_status(400) == "error"
assert _outcome_from_status(404) == "error"
assert _outcome_from_status(500) == "error"
# ---------------------------------------------------------------------------
# Middleware integration
# ---------------------------------------------------------------------------
class TestAuditMiddleware:
@pytest.fixture
def audit_publisher(self):
pub = AsyncMock()
pub.emit = AsyncMock()
return pub
@pytest.fixture
def middleware(self, audit_publisher):
return make_audit_middleware(audit_publisher)
@pytest.mark.asyncio
async def test_emits_event_on_success(self, middleware, audit_publisher):
async def handler(request):
return web.json_response({"ok": True})
req = _make_request()
await middleware(req, handler)
audit_publisher.emit.assert_called_once()
event_type, payload = audit_publisher.emit.call_args[0]
assert event_type == "gateway.request"
assert payload["method"] == "POST"
assert payload["path"] == "/api/v1/config"
assert payload["status_code"] == 200
assert payload["outcome"] == "success"
assert "request_id" in payload
assert "duration_ms" in payload
assert "error" not in payload
@pytest.mark.asyncio
async def test_emits_event_on_http_exception(self, middleware,
audit_publisher):
async def handler(request):
raise web.HTTPForbidden(reason="access denied")
req = _make_request()
with pytest.raises(web.HTTPForbidden):
await middleware(req, handler)
audit_publisher.emit.assert_called_once()
_, payload = audit_publisher.emit.call_args[0]
assert payload["status_code"] == 403
assert payload["outcome"] == "denied"
assert payload["error"] == "access denied"
@pytest.mark.asyncio
async def test_emits_event_on_unhandled_exception(self, middleware,
audit_publisher):
async def handler(request):
raise ValueError("boom")
req = _make_request()
with pytest.raises(ValueError):
await middleware(req, handler)
audit_publisher.emit.assert_called_once()
_, payload = audit_publisher.emit.call_args[0]
assert payload["status_code"] == 500
assert payload["outcome"] == "error"
assert payload["error"] == "ValueError"
@pytest.mark.asyncio
async def test_includes_identity_when_annotated(self, middleware,
audit_publisher):
async def handler(request):
request['audit_identity'] = "user:mark"
return web.json_response({"ok": True})
req = _make_request()
await middleware(req, handler)
_, payload = audit_publisher.emit.call_args[0]
assert payload["identity"] == "user:mark"
@pytest.mark.asyncio
async def test_includes_capability_when_annotated(self, middleware,
audit_publisher):
async def handler(request):
request['audit_capability'] = "config:read"
request['audit_workspace'] = "production"
return web.json_response({"ok": True})
req = _make_request()
await middleware(req, handler)
_, payload = audit_publisher.emit.call_args[0]
assert payload["capability"] == "config:read"
assert payload["workspace"] == "production"
@pytest.mark.asyncio
async def test_omits_unannotated_fields(self, middleware,
audit_publisher):
async def handler(request):
return web.json_response({"ok": True})
req = _make_request()
await middleware(req, handler)
_, payload = audit_publisher.emit.call_args[0]
assert "identity" not in payload
assert "capability" not in payload
assert "workspace" not in payload
@pytest.mark.asyncio
async def test_assigns_request_id(self, middleware, audit_publisher):
captured_id = None
async def handler(request):
nonlocal captured_id
captured_id = request.get('audit_request_id')
return web.json_response({"ok": True})
req = _make_request()
await middleware(req, handler)
assert captured_id is not None
_, payload = audit_publisher.emit.call_args[0]
assert payload["request_id"] == captured_id
@pytest.mark.asyncio
async def test_still_returns_response_if_emit_fails(self, middleware,
audit_publisher):
audit_publisher.emit.side_effect = RuntimeError("pub/sub down")
async def handler(request):
return web.json_response({"ok": True})
req = _make_request()
resp = await middleware(req, handler)
assert resp.status == 200