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
This commit is contained in:
cybermaggedon 2026-07-06 10:47:49 +01:00 committed by GitHub
parent 12ea6d46bc
commit d5f3b6d9f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1426 additions and 18 deletions

View file

@ -46,4 +46,5 @@ from . reranker_client import RerankerClientSpec
from . reranker_service import RerankerService
from . row_embeddings_query_client import RowEmbeddingsQueryClientSpec
from . collection_config_handler import CollectionConfigHandler
from . audit_publisher import AuditPublisher

View file

@ -0,0 +1,42 @@
import json
import logging
from datetime import datetime, timezone
from uuid import uuid4
from . producer import Producer
from . metrics import ProducerMetrics
from trustgraph.schema import AuditEvent, audit_events_queue
logger = logging.getLogger(__name__)
class AuditPublisher:
def __init__(self, backend, component_name, processor_id=None):
self.component_name = component_name
self.producer = Producer(
backend=backend,
topic=audit_events_queue,
schema=AuditEvent,
metrics=ProducerMetrics(
processor=processor_id or component_name,
flow=None,
name="audit-events",
),
)
async def emit(self, event_type, payload):
event = AuditEvent(
schema_version=1,
event_id=str(uuid4()),
event_type=event_type,
timestamp=datetime.now(timezone.utc).isoformat(),
producer=self.component_name,
payload_json=json.dumps(payload),
)
try:
await self.producer.send(event)
except Exception as e:
logger.warning(f"Failed to emit audit event: {e}")

View file

@ -62,7 +62,8 @@ class IamClient(RequestResponse):
)
return resp.user
async def authenticate_anonymous(self, timeout=IAM_TIMEOUT):
async def authenticate_anonymous(self, timeout=IAM_TIMEOUT,
request_id="", client_ip=""):
"""Request anonymous access from the IAM regime.
Returns ``(user_id, default_workspace, roles)`` if the regime
@ -70,6 +71,8 @@ class IamClient(RequestResponse):
error type ``auth-failed`` if it does not."""
resp = await self._request(
operation="authenticate-anonymous",
request_id=request_id,
client_ip=client_ip,
timeout=timeout,
)
return (
@ -78,7 +81,8 @@ class IamClient(RequestResponse):
list(resp.resolved_roles),
)
async def resolve_api_key(self, api_key, timeout=IAM_TIMEOUT):
async def resolve_api_key(self, api_key, timeout=IAM_TIMEOUT,
request_id="", client_ip=""):
"""Resolve a plaintext API key to its identity triple.
Returns ``(user_id, default_workspace, roles)`` or raises
@ -87,6 +91,8 @@ class IamClient(RequestResponse):
resp = await self._request(
operation="resolve-api-key",
api_key=api_key,
request_id=request_id,
client_ip=client_ip,
timeout=timeout,
)
return (
@ -96,7 +102,8 @@ class IamClient(RequestResponse):
)
async def authorise(self, identity_handle, capability,
resource, parameters, timeout=IAM_TIMEOUT):
resource, parameters, timeout=IAM_TIMEOUT,
request_id="", client_ip=""):
"""Ask the IAM regime whether ``identity_handle`` may perform
``capability`` on ``resource`` given ``parameters``.
@ -111,6 +118,8 @@ class IamClient(RequestResponse):
capability=capability,
resource_json=json.dumps(resource or {}, sort_keys=True),
parameters_json=json.dumps(parameters or {}, sort_keys=True),
request_id=request_id,
client_ip=client_ip,
timeout=timeout,
)
return resp.decision_allow, resp.decision_ttl_seconds
@ -186,7 +195,7 @@ class IamClient(RequestResponse):
)
async def login(self, username, password, workspace="",
timeout=IAM_TIMEOUT):
timeout=IAM_TIMEOUT, request_id="", client_ip=""):
"""Validate credentials and return ``(jwt, expires_iso)``.
``workspace`` is optional; defaults at the server to the
OSS default workspace."""
@ -195,6 +204,8 @@ class IamClient(RequestResponse):
workspace=workspace,
username=username,
password=password,
request_id=request_id,
client_ip=client_ip,
timeout=timeout,
)
return resp.jwt, resp.jwt_expires

View file

@ -16,4 +16,5 @@ from .collection import *
from .storage import *
from .tool_service import *
from .sparql_query import *
from .reranker import *
from .reranker import *
from .audit import *

View file

@ -0,0 +1,27 @@
from dataclasses import dataclass, field
from ..core.topic import queue
############################################################################
# Audit events — see docs/tech-specs/audit-events.md for the full spec.
#
# Transport: notify-class pub/sub (fire-and-forget, per-subscriber
# delivery). Producers are the API gateway and the IAM service.
# Consumers are optional enterprise components.
@dataclass
class AuditEvent:
schema_version: int = 1
event_id: str = ""
event_type: str = ""
timestamp: str = ""
producer: str = ""
payload_json: str = ""
audit_events_queue = queue('audit-events', cls='notify')
############################################################################

View file

@ -121,6 +121,10 @@ class IamRequest:
group: GroupInput | None = None
grant: GrantInput | None = None
# ---- Audit context (informational, echoed into audit events) ----
request_id: str = ""
client_ip: str = ""
# ---- authorise / authorise-many inputs ----
# Capability string from the vocabulary in capabilities.md.
capability: str = ""