AuditFlow
Overview
AuditFlow is a central router for audit events. Your services publish an event once with a single REST call; AuditFlow buffers it on a message broker, redacts sensitive fields, evaluates the tenant’s pipelines and delivers the result to one or more destinations (sinks). Routing is configuration, not code.
AuditFlow is a router, not a system of record. It has no database of its own: the sinks (OpenSearch, S3, ClickHouse, Splunk, …) own retention, immutability and query. Durability comes from confirmed broker buffering, per-pipeline retries over hours, a per-tenant dead-letter queue and the destination sink.
AuditFlow complements OpenTelemetry rather than replacing it. OpenTelemetry answers “is my system healthy?” with sampled, disposable telemetry; AuditFlow answers “who did what, when, and with what outcome?” and treats every event as one that must not be lost.
Capabilities
| Capability | Description |
|---|---|
| Tenant isolation | Every pipeline belongs to exactly one tenant. Events route only through their own tenant’s pipelines; there is no cross-tenant fall-through. |
| Pipeline-as-configuration | Destinations and conditions are YAML. Adding or rerouting one needs no rebuild and no restart. |
| Redaction at ingest | Fields are masked, hashed or dropped before the event reaches the broker. |
| Pluggable sinks and transformers | Python modules loaded at runtime; a catalogue ships in the box. |
| Resilient delivery | Idempotent processing, retry tiers, backpressure, circuit breaking and a replayable per-tenant DLQ. |
| Observable | OpenTelemetry traces, metrics and logs with a pre-provisioned Grafana dashboard. |
The full list is in Features. To see which usage fits yours, read Usage scenarios.
Architecture
flowchart LR
subgraph Publishers
S1[Your services]
end
subgraph AuditFlow
API[REST API<br/>redact + validate]
MQ[(RabbitMQ or Amazon SQS<br/>delivery queue, delays, per-tenant DLQ)]
R[Router<br/>one delivery per matching pipeline]
W[Delivery worker]
T[Transformer service]
K[Sink service]
end
subgraph Destinations
D1[(OpenSearch)]
D2[(S3 / Blob)]
D3[SIEM / webhook]
end
S1 -->|POST /audit/publish| API --> MQ --> R --> W
W --> T --> K
K --> D1
K --> D2
K --> D3
Three independently deployable services:
| Service | Stack | Port | Role |
|---|---|---|---|
auditflow-be | Java 25, Spring Boot 4 | 8080 | REST API, broker, pipeline orchestration |
auditflow-transformer | Python 3.14, FastAPI | 8081 | Dynamically loaded transform modules |
auditflow-sink | Python 3.14, FastAPI | 8082 | Dynamically loaded sink modules |
Key design decisions:
- No database of its own. Redis (Valkey) is used for deduplication keys with a TTL, never as storage. If you need a queryable record, point a pipeline at a durable sink.
- The backend is both producer and consumer of the same broker topic, which gives decoupling and buffering without a separate ingestion service.
- Pipelines are independent. Each gets its own delivery message, retries and DLQ entry, so a failing destination never delays or repeats delivery to the others.
- Python services are stateless plugins. They hold no broker connection or pipeline state; they receive a request, run a module function and return a result.
- RabbitMQ or Amazon SQS. The broker is configuration (
auditflow.broker.type): RabbitMQ with standard AMQP features only (publisher confirms, durable queues, per-queue TTL with dead-letter exchanges, no plugin), or Amazon SQS with no broker to run. Both give the same guarantees; the differences (event size, retention) are in Choose a message broker. Kafka is not supported.
Quick Start
Run AuditFlow on its own with Docker Compose. Two ways, with the same stack:
From the published images (needs only Docker and Git, nothing to build):
git clone https://github.com/Labs64/labs64.io-auditflow.git
cd labs64.io-auditflow
docker compose -f docker-compose.yml -f docker-compose.images.yml up -d
Set AUDITFLOW_VERSION (for example 0.1.6) to pin a release; the default is latest. The central authorization decision point (Cerbos) is not started in this mode, because the local stack runs with authorization disabled.
From source (needs JDK 25 and Maven 3.6.3+; also starts Cerbos):
mvn -B clean package -DskipTests -pl auditflow-be -am
docker compose up --build -d
Either way the stack is the backend, transformer and sink services plus RabbitMQ, Valkey and ClickHouse. The backend needs about half a minute after start to become healthy.
Publish an event for the demo tenant that ships with the repository, then watch it arrive:
curl -sS -i -X POST http://localhost:8080/audit/publish \
-H 'Content-Type: application/json' \
-d '{"eventType":"user.login","sourceSystem":"keycloak","tenantId":"demo","extra":{"userId":"alice"}}'
docker compose exec clickhouse clickhouse-client --user auditflow --password auditflow \
--query "SELECT event_type, source_system, tenant_id, user_id FROM audit.audit_events ORDER BY event_time DESC LIMIT 5"
The row shows user_id as ***: the demo configuration masks extra.userId at ingest, before the event reaches the broker or any destination.
Tear down with docker compose down -v.
To also start OpenTelemetry Collector, Tempo, Loki, Prometheus and Grafana, add the observability overlay:
docker compose -f docker-compose.yml -f docker-compose-observability.yml up --build -d
| URL | Purpose |
|---|---|
http://localhost:8080/swagger-ui.html | Interactive REST API |
http://localhost:8081/docs | Transformer registry and API docs |
http://localhost:8082/docs | Sink registry and API docs |
http://localhost:15673 | RabbitMQ management UI (guest / guest) |
http://localhost:8123/play | ClickHouse query console (auditflow / auditflow) |
http://localhost:3000 | Grafana, observability overlay only (admin / admin) |
Run one module locally walks through this in detail. To call AuditFlow through the gateway, with authentication, take the Quickstart.
Query the stored events
The default stack ships ClickHouse as a working analytics sink, so events are not only delivered but queryable. The demo tenant and the reserved _platform tenant both route to ClickHouse:
docker compose exec clickhouse clickhouse-client --user auditflow --password auditflow \
--query "SELECT event_time, event_type, source_system FROM audit.audit_events ORDER BY event_time DESC LIMIT 20"
tenant_id leads the table’s ORDER BY key and a tenantless event stores an empty string there, so publish with "tenantId": "demo" for a representative round trip. The table and its design are described under ClickHouse.
Event fields
AuditEvent requires only eventType and sourceSystem. eventId is optional: if you omit it the server assigns one and returns it in the X-Audit-Event-Id response header, and supplying a stable value makes retries idempotent. The server also assigns timestamp (the receipt time); a client value is ignored. eventTime (when the action happened) is optional: see Time fields.
Everything your domain needs goes in extra, an open map. No key in it is required, and none is guaranteed. Every deployment defines its own field set, and keys AuditFlow does not recognise are delivered unchanged in the sink’s metadata map, so an event never loses data by using your own names.
A small convention sits on top: the generic audit-semantics keys userId, actionName, actionStatus, actionMessage, durationMs and responseStatus, which the bundled transformers promote out of the map into dedicated fields and columns. Promotion is what turns a key into a queryable report dimension. All of them are optional, and an absent key produces an omitted field, never a placeholder.
Four rules every bundled transformer and sink follows:
| Rule | Meaning |
|---|---|
| Open | No extra key is required. |
| Absent is absent | A missing key yields an omitted field, never "unknown" or "N/A". |
| Nothing is dropped | Non-promoted keys always reach the sink. |
| Uniform extension | Every bundled transformer takes the same make_transform call and environment mapping. |
A domain-specific module may require its own keys, provided it documents them and gates on eventType first.
Time fields: event time and receipt time
An event has exactly two times, and they mean different things:
| Event time | Receipt time | |
|---|---|---|
| Field | eventTime | timestamp |
| Means | when the audited action happened at the source | when AuditFlow received the event |
| Set by | you; AuditFlow uses the receipt time when you omit it | AuditFlow only; a client value is ignored |
| Use it for | reports, trends, “when did it happen” | arrival order, deduplication, an archive’s date folders |
The two are equal when you publish as the action happens, and differ for a backfill, a retried publish or a publisher whose clock is off. Group reports on eventTime: grouping on timestamp puts a backfill of two years of history into today’s bucket. In ClickHouse, OpenSearch and Athena the same two times are event_time and timestamp (eventtime in Athena).
Send eventTime when you publish later than the action (a queue, a batch import, an offline client). Leave it out when you publish at once: AuditFlow then sets it to the receipt time, so it is always present downstream. It is kept exactly as you send it, and it must be plausible: not more than 5 minutes after the receipt time (clock skew) and not older than 1095 days. Otherwise the event is rejected with 400 VALIDATION_ERROR (in a batch, that event only), and the message names the limit. Operators change the limits with auditflow.event-time.max-future and auditflow.event-time.max-age.
No other time belongs to an event. Delivery to a sink happens later, possibly hours later with retries, and is not recorded in it. Which time each sink uses.
To promote your own keys, either build a transformer module on a bundled one:
from audit_clickhouse import make_transform
transform = make_transform({"orderRef": "order_ref"}, module_id=__name__)
or configure it with no code at all, on the transformer container:
AUDITFLOW_PROMOTED_KEYS: '{"orderRef": "order_ref"}'
Code declares the domain and the operator overrides the code, so precedence runs: built-in vocabulary, then the make_transform() argument, then AUDITFLOW_PROMOTED_KEYS, then AUDITFLOW_PROMOTED_KEYS_<MODULE_ID>. A malformed mapping fails that module’s import and shows up in GET /registry rather than silently dropping a column.
A promoted key needs a matching column in the sink schema (for ClickHouse, an ALTER TABLE ... ADD COLUMN), or the value is silently dropped at insert, because clickhouse_sink inserts with input_format_skip_unknown_fields=1. The configuration-only path is not schema-agnostic: the column must exist before the key is promoted. The full vocabulary is documented on the Extra schema in the AuditFlow OpenAPI contract.
Configuration
Configuration is provided through environment variables or Helm values.
| Variable | Description | Default |
|---|---|---|
AUDITFLOW_BROKER_TYPE | Message broker: rabbitmq or sqs (auditflow.broker.type). | rabbitmq |
RABBITMQ_USERNAME | RabbitMQ user. Required with rabbitmq, no default. | none |
RABBITMQ_PASSWORD | RabbitMQ password. Required with rabbitmq, no default. | none |
AUDITFLOW_BROKER_SQS_REGION | AWS region of the SQS queues; empty uses the AWS SDK default. | empty |
AUDITFLOW_BROKER_SQS_QUEUE_PREFIX | Name prefix of the SQS queues. | labs64-audit- |
AUDITFLOW_REDACTION_HASH_KEY | Secret key for hash redaction rules, at least 32 characters. | none |
AUDITFLOW_PROMOTED_KEYS | JSON object mapping extra keys to columns for the transformer. | none |
On SQS, credentials come from the AWS SDK default chain (an IAM role on EKS), never from configuration; see Amazon SQS for the queues and the IAM policy.
Tenant pipelines are read from the directory set by the tenants.source.local-dir.path property (/config/tenants by default; the compose stack mounts ./tenants) or, on Kubernetes, from ConfigMaps labelled auditflow.io/tenant. Changes are picked up live, with no restart. See Configuring pipelines for the tenant file format, conditions and redaction, and Deployment for Kubernetes and managed infrastructure.
Sink and transformer modules, and their properties, are covered in Sinks and transformers.
REST APIs
The contract is OpenAPI-first and frozen within v1: changes are additive-only, and CI rejects a pull request that removes or narrows anything.
| Endpoint | Method | Description |
|---|---|---|
/audit/publish | POST | Publish one event. Answers 200 only after the broker has stored it. |
/audit/publish/batch | POST | Publish up to 100 events; the answer carries a result per event. |
/actuator/pipelines/<tenantId> | GET | Deployed pipelines, effective retry and batch settings, warnings. |
/actuator/pipelines/<tenantId>/dry-run | POST | Show which pipelines a sample event would reach and why. |
/actuator/dlq/<tenantId> | GET / POST / DELETE | Inspect, replay or purge (irreversible) the tenant’s dead-letter queue. |
/actuator/health | GET | Health check. |
/swagger-ui.html | GET | Interactive API documentation. |
Publish errors an integration should handle: 400 invalid event, 403 TENANT_NOT_PROVISIONED or TENANT_DISABLED, 413 an event larger than the gateway or the broker accepts (about 1 MiB on Amazon SQS; not retryable, in a batch a VALIDATION_ERROR for that event), 429 TENANT_RATE_LIMITED with Retry-After, and 503 when the broker did not store the event (retry with the same eventId).
Examples
Custom transformer plugin
Create a transformer that masks a field (transformers/mask.py):
def transform(input_data: dict) -> dict:
extra = input_data.get("extra", {})
if "email" in extra:
extra["email"] = "***@***.com"
return input_data
Reference it from a pipeline with transformer.name: mask. For redaction that must happen before the broker, use ingest-time redaction instead; a transformer runs after the event is buffered.
More: Sinks and transformers.
Operations
AuditFlow scales horizontally. Increase replicaCount in your Helm values to process more events concurrently; the broker distributes deliveries across the replicas. Running more than one replica requires Redis, so that rate limits and deduplication are shared. See Running without Redis.
Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
| Events not reaching a sink | Pipeline condition mismatch | Use the pipeline dry run to see which pipelines the event reaches and why. |
403 TENANT_NOT_PROVISIONED | No tenant file for the event’s tenantId | Add tenants/<tenantId>.yaml; it is picked up live. |
| Deliveries in the DLQ | Retries exhausted, or a poison event (4xx, malformed transformer output) | Inspect GET /actuator/dlq/<tenantId>, fix the cause, then replay with POST. |
| A sink never receives data on Kubernetes | NetworkPolicy allows only HTTPS egress | Add a matching networkPolicy.extraEgress rule for the sink’s port. |
| Python plugin fails | Syntax error, or wrong function signature | Check the sink or transformer service logs; GET /registry lists modules that failed to import. |
| RabbitMQ connection error | Bad credentials or network | Verify RABBITMQ_USERNAME, RABBITMQ_PASSWORD and the broker host. |
SQS AccessDenied or QueueDoesNotExist | IAM role or queue prefix | The pod’s role needs the SQS policy on <queue-prefix>*; with create-queues: false every queue must exist. /actuator/health reports the sqs component. |
413 from /audit/publish on SQS | Event above about 1 MiB | Send less (for example trim extra); SQS cannot carry larger messages. |