Audit pipeline
The audit pipeline is what moves events from the authentication hot path to the storage layers that compliance, incident response, and operations consume. The pipeline has two streams (regulatory and analytics), three retention tiers (hot, archived, deleted), and a small number of trait surfaces that adopters implement against their own storage.
The chapter pairs with Audit events, which catalogues what flows through the pipeline; this chapter covers how the flow itself works.
The dual stream
Two audiences read the audit trail. Compliance auditors want completeness, immutability, and unambiguous provenance; they will accept slow queries and rigid schemas in exchange. SOC and operations teams want low query latency, flexible aggregation, and enrichment with operational context (geo lookups, ASN data, parsed user-agent strings); they accept some loss of fidelity and some divergence from the wire format in exchange.
The two requirements conflict. A single store optimised for one audience disserves the other. The pipeline's answer is to fan out: the same event flows into two streams, each shaped for its audience.
The regulatory stream uses AuthEvent directly. The shape is
exactly what the catalogue in Audit events describes: stable
fields, no enrichment, byte-for-byte uniform across deployments.
The stream feeds the regulatory store, which is typically a
database or a log archive with strong durability and immutability
guarantees.
The analytics stream uses RichAuthnEvent, a denormalised wrapper
that adds optional enrichment fields (device trust level, geo
lookup, parsed user-agent, ASN, configurable tags). The deployment
populates whatever of them it wants when it builds the
RichAuthnEvent around the AuthEvent; axess defines the shape so
downstream consumers can be shared between adopters, and does not
populate the fields for you. The stream feeds the analytics store, which is typically a columnar
database (ClickHouse, DuckDB) or a streaming platform (Apache
Iggy with rkyv).
the authentication operation
│
▼ awaited, once
IdentityAuthnLog::record_event <- you implement this
│
┌────────────┴────────────┐ <- and everything below
▼ ▼
AuthnAnalyticsSink AuditArchiver
(RichAuthnEvent) (cold tier)
│ │
▼ ▼
analytics store archive store
Axess does not fan out. It calls record_event and
awaits it; whether that write also feeds an analytics stream or an
archive is decided by the implementation you supply.
Reliability, and who owns it
Axess does not buffer, queue, retry or fan out audit events. It builds
the AuthEvent on the authentication path and awaits one call:
IdentityAuthnLog::record_event. That is the whole of the pipeline
axess ships, and everything after it is the implementation you provide.
Two consequences follow, and they are the reason to read this section before choosing a sink.
The write is on the hot path. Whatever record_event does, the
login waits for it. A sink that talks to the network over a slow link
makes logins slow. If you want the latency off the request, buffer
inside your own implementation: return as soon as the event is durable
somewhere you trust, and dispatch onward in the background. That is a
choice axess deliberately leaves to you, because the right answer
depends on whether losing an event is worse than slowing a login, and
only the deployment knows that.
A failed write fails the login. Since 0.6.0, record_event
returning an error surfaces as AuthnError::Store (and on OAuth paths
as OAuthError::AuditStore), and the authentication does not complete.
An authentication that leaves no evidence has not, for evidence
purposes, happened. Earlier versions logged the error and continued,
which meant an audit outage silently produced authentications nobody
could later account for.
This trades availability for evidence, and the trade has a sharp edge:
logins fail while your audit store does. Put the sink behind
something durable: write locally, ship onward in the background
rather than a remote service on the request path, and page on
AuthnMetrics::audit_store_outage.
To drop an event without failing the login, return
AuditOutcome::Shed. That is the valve for the hazard this creates:
every failed login writes a row, including for identifiers that do not
exist, so an unauthenticated caller can drive writes at your storage
without bound, and exhausting it would otherwise fail every login for
every user. A shed event continues the flow and fires
AuthnMetrics::audit_event_shed.
Shed on a criterion independent of the identifier: a global rate, a queue depth, a disk watermark. Shedding based on anything derived from which identifier was tried makes the drop observable per identifier, and rebuilds the user-enumeration oracle that emitting unattributed events exists to close.
axess-events has two wrappers worth knowing, and both are for the
analytics stream: they implement EventSink, not IdentityAuthnLog.
LogAndSwallow turns a sink's errors into log lines, which is the
fail-soft shape written once, and NoopEventSink discards everything,
which is what tests want when the stream is switched off.
Neither can be wrapped around the regulatory sink, and that is
deliberate. Swallowing an IdentityAuthnLog error is exactly the
fail-open this release removed; the way to drop a regulatory event is
AuditOutcome::Shed, which is explicit and counted.
The two sinks
One is the record; the other is the stream you can drop.
The IdentityAuthnLog sink
The regulatory sink is the IdentityAuthnLog implementation the
application already provides for the lockout policy (covered in
Identity store implementation). The pipeline writes events to
this sink as the canonical record. The sink's storage backend is
the application's choice; the typical pattern is a Postgres or
MySQL table with append-only writes and an index on
(user_id, tenant_id, timestamp) for the lockout-policy queries.
The pattern means the regulatory store is what the application already needs for lockout. The pipeline does not add a second database; it just uses what is already there.
The AuthnAnalyticsSink
The analytics sink is the optional stream for the SIEM and analytics consumers. The trait:
pub trait AuthnAnalyticsSink: Send + Sync + 'static {
type Error: std::error::Error + Send + Sync + 'static;
fn record_rich(
&self,
event: RichAuthnEvent,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
/// A stable name for this sink, used in log lines and metrics.
fn name(&self) -> &'static str;
}
The sink is a fire-and-forget dispatcher. A failed dispatch is
logged and dropped; the buffer's retry semantics handle the
transient cases. The implementations the
audit-archive-fs feature provides cover the filesystem case;
for streaming or columnar stores, the implementation is the
application's.
A typical Apache Iggy implementation:
struct IggyAnalyticsSink {
client: IggyClient, // from the `iggy` crate
topic: String,
}
impl AuthnAnalyticsSink for IggyAnalyticsSink {
type Error = MySinkError;
async fn record_rich(&self, event: RichAuthnEvent) -> Result<(), Self::Error> {
let bytes = rkyv::to_bytes::<_, 256>(&event)?;
self.client.send(self.topic.clone(), bytes.to_vec()).await?;
Ok(())
}
fn name(&self) -> &'static str {
"iggy"
}
}
The rkyv serialisation is the recommendation. RichAuthnEvent
derives rkyv::Archive, rkyv::Serialize, and
rkyv::Deserialize, which produces a wire format that is
significantly more compact than JSON, much faster to serialise,
and zero-copy on the deserialise side. For a stream that pumps
millions of events per day, the difference is operationally
meaningful.
A ClickHouse implementation is the equivalent for batch shipping: the sink accumulates events in memory until a threshold (batch size or time interval), then issues a bulk insert. The pattern matches ClickHouse's preferred ingestion shape.
Retention and archival
How long rows live, and what moves them off the hot store.
The three-tier retention
The regulatory stream's events grow without bound by default. A deployment with millions of users produces hundreds of millions of events per year; the storage cost and the query cost both trend up unless the deployment manages the retention.
The retention story has three tiers, with explicit transitions between them.
The hot tier is the live authn_attempts table (or whatever the
regulatory sink writes to). Events stay in the hot tier for as
long as they are operationally useful: the lockout policy's
last_attempts query, the SIEM's recent-events dashboards, the
incident-response window. The recommended hot retention is
between 7 and 90 days, with 30 days as a sensible default for
most deployments.
The archived tier is a cheaper, slower store that holds events for the compliance retention period. The data is the same; the access pattern is different. Queries against the archive are slower (typically minutes rather than milliseconds) and less flexible (no indexed lookup; full-scan reads against a known date range). The archive is the answer to "show me everything that happened to this user three years ago." The retention here is set by the regulatory regime: PCI-DSS asks for one year; banking regulations ask for seven years; HIPAA asks for six years. Configure to match.
The deleted tier is what comes after the archive expires. The events are removed entirely and the underlying data is gone. Record the deletion itself somewhere durable, with the date range and the count: axess has no event for it, and an expiry nobody wrote down is indistinguishable from a gap. Some deployments never reach this tier (an indefinite archive is a defensible choice for small-volume deployments); others rotate through it on the regulatory schedule.
AuditArchiver
The transition from hot to archived runs through the
AuditArchiver trait:
pub trait AuditArchiver: Send + Sync + 'static {
type Error: std::error::Error + Send + Sync + 'static;
fn archive_batch(
&self,
events: &[AuthEvent],
) -> impl Future<Output = Result<(), Self::Error>> + Send;
/// A stable name for this archiver, used in log lines and metrics.
fn name(&self) -> &'static str;
}
The trait has one write verb and a name. archive_batch takes the
events by slice, so an implementation that streams them does not force
an allocation, and returns your own Self::Error.
There is no purge verb. Deleting from the cold store is the cold store's business (an S3 lifecycle rule, a partition drop, a retention setting on the object bucket), and it is usually configured where the storage lives rather than driven from the application. An archiver that wants to purge on a schedule does it inside its own implementation. Axess never calls it.
The pipeline runs an AuditRetentionLoop<S, A> (S is the source
IdentityAuthnLog, A is the archiver) that drives the
transitions on a configurable schedule:
let retention_policy = AuditRetentionPolicy {
archive_after: Duration::from_secs(30 * 86400), // 30 days; default is 90
purge_hot_after_archive: Duration::from_secs(7 * 86400),
delete_archive_after: None, // never purge archive
};
let loop_handle = AuditRetentionLoop::new(
retention_source,
my_archiver,
retention_policy,
)
.with_tick_interval(Duration::from_secs(3600)) // the default
.with_batch_size(10_000) // the default
.spawn(); // JoinHandle<()>
The loop ticks once per tick_interval, hourly by default, archiving
up to batch_size events per tick. Each tick reads the hot-tier events
that have aged past archive_after, hands them to the archiver, and
purges the hot rows whose archive copy was made more than
purge_hot_after_archive ago. spawn returns a JoinHandle; the loop
runs until that handle is dropped. tick is public too, for a
deployment that would rather drive it from its own scheduler and read
the RetentionTickReport each pass returns.
The delete_archive_after field is the optional final
transition. None means the archive grows indefinitely; a
configured duration means the archive itself is purged at that
age.
The defaults (90 days hot, then the verification window before the hot
row goes, and no archive deletion) are conservative for finance.
delete_archive_after: None is the right default for most adopters,
because regulators penalise premature deletion far more harshly than
they reward storage savings. PCI-DSS asks
for one year of audit retention, which the defaults satisfy by
keeping events in the archive indefinitely. Other regulatory
regimes have different requirements; tune to match.
Filesystem archive
The audit-archive-fs feature ships
FilesystemAuditArchiver, a reference implementation that
writes archived events to a day-partitioned JSONL directory:
/var/lib/axess/audit/
YYYY-MM-DD.jsonl
YYYY-MM-DD.jsonl
YYYY-MM-DD.jsonl
...
Each file is append-only, fsynced per batch, and contains
newline-delimited JSON-encoded events. The format is readable by
standard tools (grep, jq, awk), survives forensic
investigation, and lifts cleanly into cloud object storage when
the deployment moves the archive there.
The reference implementation is for deployments with straightforward audit-storage needs. Larger deployments typically use S3 (with object-lock for immutability), GCS (with retention policies), or a dedicated audit-log service (Splunk, Datadog, SumoLogic). The trait surface is the same; the implementation is the deployment's.
Backpressure and tenant isolation
Since the buffer is yours, so is the backpressure. Worth deciding before a busy tenant decides it for you.
AuthEvent carries tenant_id, so a sink can route per tenant: its
own buffer, its own retention, its own destination. That is what makes
a per-tenant audit SLA real rather than a deployment-wide average, and
it keeps one tenant's spike off another's stream. The cost is a
configuration per tenant and the operational surface that comes with
it.
A single shared sink with conservative behaviour is fine for most deployments, and is the sensible starting point. Reach for per-tenant routing when a contract names a number.
What axess will not do is stop authenticating because the audit is behind. It has no policy to drop, block or shut down on a full buffer, because it has no buffer. If you need a fail-shut posture, see Reliability above.
From events to a defensible trail
The pipeline is what turns axess's audit events into a defensible production audit trail. The dual stream serves the two audiences; the buffer absorbs latency without blocking the hot path; the retention tiers balance storage cost against query needs and regulatory requirements. The mechanism is small (a handful of traits, one fan-out, one retention loop), and the configuration is the deployment's lever for tuning to specific requirements.
Further reading
Audit events catalogues what flows through the pipeline.
Identity store implementation covers the regulatory sink
(the IdentityAuthnLog trait). Multi-tenancy covers the
per-tenant configuration patterns. Security posture covers
the GDPR posture for archived audit data and the PII fields
that may need scrubbing before archive.