Middleware & Auth
Audit Logging
New to acton-service?
Start with the homepage to understand what acton-service is, then explore Core Concepts for foundational explanations. See the Glossary for technical term definitions.
Tamper-evident audit logging with BLAKE3 hash chaining, automatic auth event capture, and SIEM export via Syslog RFC 5424 and OpenTelemetry Logs.
Overview
acton-service provides immutable audit trails for security compliance. Every audit event is sealed into a BLAKE3 hash chain, guaranteeing tamper detection. Events are processed sequentially by an internal actor, persisted to your configured database backend with append-only enforcement, and exported to SIEM systems via Syslog or OTLP.
Actor-Based Processing
Audit events are processed by an AuditAgent (acton-reactive actor) that owns the hash chain state and guarantees correct ordering. Events are sent via fire-and-forget message passing, so audit logging never blocks request handling. Events emitted during startup — including the ConfigLoaded compliance event — are buffered until the hash chain initializes, then sealed in emission order, so nothing is lost while storage connects. See Reactive Architecture for details.
How It Works
HTTP Request --> AuditMiddleware --> ActorHandle::send() --> AuditAgent --+--> DB (append-only)
Auth Events --> AuditLogger.log() -------------------------+ +--> Syslog RFC 5424
+--> OTLP Logs (optional)
Feature Interactions
| Features Enabled | Behavior |
|---|---|
audit alone | In-memory hash chain + syslog export |
audit + database/turso/surrealdb | Persistent append-only storage |
audit + clickhouse | Analytical audit storage (MergeTree, time-partitioned) |
audit + observability | OTLP log export via tracing |
audit + token auth (PASETO/JWT) | Automatic auth event emission |
Installation
Enable the audit feature:
[dependencies]
acton-service = { version = "0.47.0", features = ["audit"] }
With a database backend for persistent storage:
[dependencies]
acton-service = { version = "0.47.0", features = ["audit", "database", "http", "observability"] }
Or with ClickHouse for analytical audit storage:
[dependencies]
acton-service = { version = "0.47.0", features = ["clickhouse", "audit", "http", "observability"] }
Configuration
Add an [audit] section to your config.toml:
[audit]
enabled = true
audit_all_requests = false # Audit every HTTP request
audit_auth_events = true # Auto-audit auth events (login, logout, etc.)
otlp_logs_enabled = false # Export via OTLP (requires observability feature)
audited_routes = ["/api/v1/admin/*"] # Glob patterns for per-route auditing
excluded_routes = ["/health", "/ready", "/metrics"]
[audit.syslog]
transport = "udp" # "udp", "tcp", or "none"
address = "127.0.0.1:514"
facility = 13 # 13 = audit (RFC 5424)
# app_name = "my-service" # Defaults to service.name
Environment Variable Override
ACTON_AUDIT__ENABLED=true
ACTON_AUDIT__AUDIT_ALL_REQUESTS=true
ACTON_AUDIT__SYSLOG__TRANSPORT=tcp
ACTON_AUDIT__SYSLOG__ADDRESS=syslog.example.com:514
Configuration Options
- enabled: Enable or disable audit logging globally (default:
true) - audit_all_requests: Log every HTTP request as an audit event (default:
false) - audit_auth_events: Automatically emit events for auth actions (default:
true) - otlp_logs_enabled: Emit audit events as structured tracing log records for the configured logging/export pipeline (default:
false, requiresobservabilityfeature). Starting in 0.46.0, the writer honors this switch. Set it totrueif you relied on the previous unconditional audit log records. Storage and syslog export are configured independently. - audited_routes: Glob patterns for routes to audit (e.g.,
"/api/v1/admin/*") - excluded_routes: Routes to never audit, even when
audit_all_requestsis true (default:["/health", "/ready"])
Basic Usage
The audit logger is available via AppState when the audit feature is enabled:
use acton_service::prelude::*;
async fn delete_user(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<()>> {
// Perform the deletion
// ...
// Log a custom audit event
if let Some(logger) = state.audit_logger() {
logger.log_custom(
"user.delete",
AuditSeverity::Warning,
Some(serde_json::json!({ "user_id": id })),
).await;
}
Ok(Json(()))
}
Auth Events (Automatic)
When audit_auth_events is enabled (default), the PASETO and JWT middleware automatically emit audit events:
| Event Kind | When Emitted | Severity |
|---|---|---|
AuthLoginSuccess | Token validated successfully | Notice |
AuthTokenMissing | Bearer token missing or malformed on a protected route | Informational |
AuthTokenInvalid | Bearer token failed validation (bad signature, expired, malformed claims) | Warning |
AuthTokenRevoked | Revoked token presented. Event metadata carries jti for SIEM correlation. | Warning |
AuthPermissionDenied | Cedar policy returned Deny (HTTP middleware and gRPC tower service) | Warning |
HttpRequestDenied | Rate-limit rejection (Error::RateLimitExceeded), from both the Redis-backed and governor limiters | Warning |
AuthLogout | TypedSession<AuthSession>::logout() called; subject is the previously authenticated user | Notice |
AuthLoginFailedis no longer emitted by the auth middleware. It is reserved for application-level login handlers (e.g.POST /auth/login) where credentials are submitted. The middleware emitsAuthTokenMissingorAuthTokenInvalidinstead, so unauthenticated probes against protected routes (health checks, scanners) no longer drown out real failed-login signal. See the 0.27 release notes for the migration.
No additional code is required. These events include the client IP, user agent, request ID, and authenticated subject. The IP and request ID come from the request-context middleware, which resolves them once per request — from X-Forwarded-For / X-Real-IP when an upstream proxy supplies them, falling back to the direct TCP peer address otherwise — and runs after the request ID is generated. Token-failure events therefore carry a usable source even when a proxy strips forwarding headers (see Execution Order).
Auth Storage Decorators
Token refresh, API-key lifecycle, and OAuth callbacks run through storage and provider traits that your application constructs, so the framework cannot emit those events automatically. Wrap what you build with the decorators in acton_service::audit and every backend emits the same events:
use acton_service::audit::{AuditedApiKeyStorage, AuditedOAuthProvider, AuditedRefreshStorage};
let logger = state.audit_logger().expect("audit enabled").clone();
let refresh = AuditedRefreshStorage::new(RedisRefreshStorage::new(pool.clone()), logger.clone());
// Share the configured API-key generator with issuance and storage verification.
let api_keys = AuditedApiKeyStorage::new(RedisApiKeyStorage::new(pool, generator.clone()), logger.clone());
let google = AuditedOAuthProvider::new(google_provider, logger);
| Decorator | Emits | When |
|---|---|---|
AuditedRefreshStorage | AuthTokenRefresh | Successful token rotation; source carries the user ID plus the IP/user agent captured at issuance |
AuditedApiKeyStorage | AuthApiKeyCreated / AuthApiKeyRevoked | Successful create/revoke; metadata carries key ID, name, prefix, and scopes — never the key or its hash |
AuditedOAuthProvider | AuthOAuthCallback | Authorization-code exchange, success (Notice) or failure (Warning) |
Failed operations propagate their errors without emitting lifecycle events, and all decorator emissions honor audit_auth_events.
Per-Route Auditing
Mark specific routes for auditing with custom event names:
use acton_service::prelude::*;
let routes = VersionedApiBuilder::new()
.with_base_path("/api")
.add_version(ApiVersion::V1, |router| {
router
// These routes are audited with custom event names
.route("/admin/users/:id", delete(delete_user)
.layer(Extension(AuditRoute::new("user.delete"))))
.route("/admin/settings", put(update_settings)
.layer(Extension(AuditRoute::new("settings.update"))))
// This route is NOT audited (unless audit_all_requests is true)
.route("/users", get(list_users))
})
.build_routes();
Routes annotated with AuditRoute are always audited, regardless of the audited_routes config patterns.
Route Pattern Matching
The audited_routes config supports simple glob patterns:
[audit]
audited_routes = [
"/api/v1/admin/*", # Any single segment under /admin/
"/api/v1/admin/**", # Any path starting with /admin/
"/api/v1/users/*/delete", # DELETE-like paths with any user ID
]
Hash Chain Integrity
Every audit event is sealed into a BLAKE3 hash chain. Each event's hash covers:
- Sequence number (monotonically increasing)
- Previous event's hash (chain linkage)
- Event ID, timestamp, kind, severity
- Service name, HTTP method, path, status code
- Authenticated subject (if present)
Verifying the Chain
Use the storage method for partial ranges. It fetches the immediate predecessor alongside the requested events and verifies its content hash, consecutive sequence numbers, and every hash and link in the range.
// Sequence 1 starts at genesis; sequence 0 is an alias for 1.
match storage.verify_chain(from_sequence).await {
Ok(None) => println!("Requested range is consistent with its stored anchor"),
Ok(Some(sequence)) => eprintln!("Broken event at sequence {sequence}"),
Err(error) => eprintln!("Verification unavailable: {error}"),
}
An empty range, missing requested start, or unavailable predecessor returns an error. If retention has purged the predecessor, verification at that boundary requires a trusted checkpoint; the built-in adapters do not store checkpoints. A later range can still be checked against its retained predecessor.
The standalone audit::verify_chain(&events) function requires a complete, consecutive chain starting at sequence 1 with no predecessor. Its empty-slice result is vacuously successful and does not establish that any history exists. Neither method proves completeness against an independently trusted chain head.
The chain detects:
- Modified events: Hash won't match recalculated value
- Deleted events: Sequence gaps or broken chain links
- Reordered events: Previous hash won't match prior event
- Inserted events: Chain linkage will be broken
Database Storage
When a database feature is enabled alongside audit, events are persisted with append-only enforcement:
PostgreSQL
[dependencies]
acton-service = { version = "0.47.0", features = ["audit", "database", "http", "observability"] }
Uses CREATE RULE to prevent updates and deletes on the audit_events table.
Turso (libsql)
[dependencies]
acton-service = { version = "0.47.0", features = ["audit", "turso"] }
Uses CREATE TRIGGER ... RAISE(ABORT) to enforce immutability.
SurrealDB
[dependencies]
acton-service = { version = "0.47.0", features = ["audit", "surrealdb"] }
Uses PERMISSIONS FOR update, delete NONE on the audit table.
Append-Only Enforcement
The storage implementations create database rules/triggers that prevent any modification or deletion of audit records. This is enforced at the database level, not just in application code. A database administrator with direct schema access could still remove these protections, so protect your database credentials accordingly.
Syslog Export (RFC 5424)
Audit events are automatically formatted as RFC 5424 syslog messages and sent via UDP or TCP:
<109>1 2026-01-15T10:30:00.000Z my-service acton-audit - - [audit@0 event_id="..." kind="AuthLoginSuccess" severity="Notice" sequence="42" hash="abc123..."] User login successful
Syslog Configuration
[audit.syslog]
transport = "udp" # "udp", "tcp", or "none" to disable
address = "127.0.0.1:514" # Syslog server address
facility = 13 # RFC 5424 facility (13 = audit)
app_name = "my-service" # Override app name (defaults to service.name)
Integration with SIEM Systems
Configure your SIEM to receive RFC 5424 messages:
- Splunk: Configure a UDP/TCP input on port 514, parse structured data from the
[audit@0]SD-ELEMENT - Elastic/ELK: Use Filebeat's syslog input or Logstash's syslog filter
- Datadog: Configure a syslog source via the Datadog Agent
- Graylog: Add a Syslog UDP/TCP input
OTLP Export
When the observability feature is also enabled, audit events are exported as structured OpenTelemetry log records:
[dependencies]
acton-service = { version = "0.47.0", features = ["audit", "http", "observability"] }
[audit]
otlp_logs_enabled = true
[otlp]
endpoint = "http://otel-collector:4317"
Events are emitted via tracing::info! with structured fields:
audit.event_id = "550e8400-..."
audit.kind = "AuthLoginSuccess"
audit.severity = "Notice"
audit.sequence = 42
audit.hash = "abc123..."
audit.service = "my-service"
These fields are automatically picked up by any OpenTelemetry-compatible collector.
Custom Audit Events
Emit custom events from anywhere in your application:
use acton_service::prelude::*;
async fn process_payment(
State(state): State<AppState>,
Json(payment): Json<PaymentRequest>,
) -> Result<Json<PaymentResponse>> {
// Process payment...
let result = charge_card(&payment).await?;
// Log audit event with metadata
if let Some(logger) = state.audit_logger() {
logger.log_custom(
"payment.processed",
AuditSeverity::Informational,
Some(serde_json::json!({
"amount": payment.amount,
"currency": payment.currency,
"payment_id": result.id,
})),
).await;
}
Ok(Json(result))
}
Event Kinds
Built-in event kinds for common operations:
| Kind | Description |
|---|---|
AuthLoginSuccess | Successful authentication |
AuthLoginFailed | Failed credential-submission attempt (emit from app login handlers; the middleware no longer emits this) |
AuthTokenMissing | Bearer token missing on protected route (middleware-emitted) |
AuthTokenInvalid | Bearer token failed validation (middleware-emitted) |
AuthLogout | User logout |
AuthTokenRefresh | Token refresh |
AuthTokenRevoked | Token revocation |
AuthPasswordChanged | Password change |
AuthApiKeyCreated | API key created |
AuthApiKeyRevoked | API key revoked |
AuthOAuthCallback | OAuth callback processed |
AuthPermissionDenied | Authorization denied |
AuthAccountLocked | Account locked after repeated login failures (requires login-lockout) |
AuthAccountUnlocked | Account unlocked (requires login-lockout) |
AccountCreated | Account created (requires accounts) |
AccountDisabled | Account disabled by an administrator (requires accounts) |
AccountEnabled | Account enabled or re-activated (requires accounts) |
AccountLocked | Account locked due to a security event (requires accounts) |
AccountUnlocked | Account unlocked (requires accounts) |
AccountExpired | Account expired (requires accounts) |
AccountDeleted | Account deleted (requires accounts) |
AccountUpdated | Account updated — profile, email verification, password, or roles (requires accounts) |
AuthKeyRotated | Signing key rotated (new key active, old key draining) |
AuthKeyRetired | Signing key retired after its drain period expired |
AuthKeyRotationFailed | Key rotation failed |
ConfigLoaded | Configuration loaded at startup (NIST CM-3) |
ConfigDriftDetected | Active configuration differs from on-disk sources (NIST CM-3) |
HttpRequest | HTTP request (from middleware) |
HttpRequestDenied | Denied HTTP request (rate limit, auth failure, etc.) |
Custom(String) | Custom application event |
Severity Levels
Severity levels map to RFC 5424 syslog severity values:
| Severity | Syslog Value | Usage |
|---|---|---|
Emergency | 0 | System is unusable |
Alert | 1 | Immediate action required |
Critical | 2 | Critical conditions |
Error | 3 | Error conditions |
Warning | 4 | Warning conditions |
Notice | 5 | Normal but significant |
Informational | 6 | Informational messages |
Debug | 7 | Debug-level messages |
Audit identifiers
Starting with 0.42.0, AuditEvent.id uses the AuditEventId newtype backed by mti::MagicTypeId. Newly generated IDs have the audit_ prefix and UUIDv7 payloads. JSON, logs, and display output use the canonical TypeID string.
Deserialization also accepts legacy UUID strings. Conversion preserves all 128 UUID bits, including older UUIDv4 identities, and never generates a replacement ID. Storage keeps its existing native UUID or UUID-string encoding through AuditEventId::as_uuid(). Both v1 and v2 audit hashes still cover the original 16 UUID bytes, so converting an archived event to a TypeID does not alter its hash. Clients that validated audit IDs as UUID strings must accept the audit_ TypeID format.
Use AuditEventId::new() when constructing audit events directly, or convert an existing UUID with AuditEventId::from(uuid) when importing historical events.
Timestamp precision
Starting with 0.41.0, sealing truncates event timestamps to milliseconds before hashing. This is the common precision preserved by every built-in storage adapter, including ClickHouse. Verification continues to hash each stored timestamp exactly as recorded. Older events whose timestamp precision was already lost in storage remain unverifiable; this release does not reconstruct missing fractional digits or reinterpret historical hashes. A range anchored to such an older event may therefore report a broken predecessor.
Reading and verifying stored events
AppState::audit_storage() returns the same storage handle used by the active audit agent. It is absent when the agent has no persistent storage or did not start. Call ensure_ready() before reporting storage availability: a lazy database connection can still be pending or unavailable.
AuditStorage::sequence_bounds() reports the retained first and latest sequences. Capture the latest sequence as a page snapshot, then call query_sequence(from, snapshot, limit) for inclusive, ascending pages. Advance from to one past the last returned sequence. New appends do not enter that snapshot; retention can remove earlier events between requests. These storage methods do not authorize callers, so applications must enforce their own access policy before exposing events or bounds.
verify_chain_range(from, to) verifies an inclusive interval of at most 10,000 requested events plus its immediate predecessor. It returns AuditVerification::Consistent, Broken { sequence }, or Incomplete. Empty intervals in storage, missing endpoints, and missing predecessor anchors are incomplete. Database failures and unsupported custom adapters return an error. Sequence zero is an alias for genesis, sequence one.
A consistent result establishes local consistency against the stored predecessor. It does not authenticate history before that predecessor, prove completeness against an external chain head, or supply a trusted retention checkpoint. A corrupt predecessor can be reported as broken even though it precedes the requested interval.
Custom AuditStorage implementations retain compatibility through default methods. Implement query_sequence to enable the default bounds and bounded verification methods. AppStateBuilder::audit_storage attaches application-managed storage without spawning an audit agent.
Best Practices
DO:
- ✅ Enable
audit_auth_eventsin production (it's on by default) - ✅ Use per-route
AuditRouteannotations for sensitive operations - ✅ Configure syslog export to a SIEM for real-time monitoring
- ✅ Periodically verify hash chain integrity
- ✅ Use a persistent database backend for compliance requirements
- ✅ Exclude health check routes from auditing
DON'T:
- ❌ Enable
audit_all_requestswithout considering volume (can be noisy) - ❌ Store audit events only in-memory for production (no persistence)
- ❌ Disable database-level append-only protections
- ❌ Log sensitive data (passwords, tokens) in audit event metadata
Production Deployment
Recommended Configuration
[audit]
enabled = true
audit_all_requests = false
audit_auth_events = true
otlp_logs_enabled = true
audited_routes = ["/api/v1/admin/*", "/api/v1/billing/*"]
excluded_routes = ["/health", "/ready", "/metrics"]
[audit.syslog]
transport = "tcp" # TCP for reliable delivery
address = "syslog.internal:514"
facility = 13
Kubernetes Integration
env:
- name: ACTON_AUDIT__ENABLED
value: "true"
- name: ACTON_AUDIT__SYSLOG__TRANSPORT
value: "tcp"
- name: ACTON_AUDIT__SYSLOG__ADDRESS
value: "syslog-service.logging.svc.cluster.local:514"
Compliance Considerations
The audit system supports common compliance frameworks:
- SOC 2: Immutable audit trails with hash chain verification
- PCI DSS: Logging of authentication events and access to sensitive data
- HIPAA: Audit controls for access to protected health information
- GDPR: Record of processing activities with tamper detection
Ensure your syslog/SIEM retention policies match your compliance requirements.
Related Features
- Auth Overview - Authentication features that emit audit events
- Token Authentication - PASETO/JWT middleware with automatic audit integration
- Observability - OpenTelemetry tracing and OTLP export
- Configuration - Environment and file-based configuration
- Health Checks - Service health monitoring
Investigating retained events
AuditStorage::query_filtered(&AuditQuery) returns exact matches in sequence order. The default is newest first with a 100-event limit; valid limits are 1 through 1000. Set AuditOrder::OldestFirst for ascending results. A cursor excludes its sequence, and through_sequence fixes an inclusive upper boundary for paging while new events arrive. Keep filters, order and snapshot ceiling unchanged between pages. Sequence order reflects append order, which can differ from timestamp order.
Filters include inclusive timestamps, kind, severity, source subject, request ID, service, status, and the metadata keys schema, entity_id, and tenant_id. The actor filter matches source subject or metadata user or actor; these are recorded claims, not proof of identity. Set metadata_kinds to an exact wire-kind allowlist when only known event schemas may supply metadata filters. An empty allowlist disables metadata matches while retaining source-subject matching. Filters do not change persisted events or hashes.
PostgreSQL and SQL Server filter in the database. PostgreSQL initializes investigation indexes concurrently, which avoids blocking inserts but can consume time and I/O on a large existing store. SurrealDB filters common fields in the database and decodes at most 10000 candidates to evaluate historical JSON metadata. If that budget is exhausted before a complete page or the end is reached, it returns an error; narrow the time range or event kind and retry. Custom storage implementations return an explicit unsupported error until they implement the new method.
Successful bearer validation now emits auth.token.validated. Application login events continue to use auth.login.success; existing stored events and hashes are preserved. HTTP bearer and policy denials include method, path, status, and a fixed reason code without bearer credentials. gRPC policy denials record gRPC status 7 in metadata.