23. CADF-Compliant Phased Auditing Architecture
Date: 2026-06-16
Status
Accepted
Security review 2026-06-24: Seven findings applied — HMAC canonicalization (RFC 8785/JCS),
boot_session_idCSPRNG requirement,initiator.hostsanitization rules,refresh_hmac_keyversion-collision fix, HMAC key retention policy, cross-node spool tamper detection, andmap_event_to_actiondangling-reference correction.Schema v1.1 (2026-07-30): Two fixes applied after real-world audit logs showed successful logins and mutations recording
"unknown"identifiers and no client IP at all:
sanitize_audit_idrejected every ID Keystone actually mints. It only accepted canonical hyphenated UUIDs (36 chars); every real ID usesUuid::new_v4().simple()(32 hex chars, no hyphens). Now accepts both shapes.Initiator.host— a bareStringcarrying only the pre-auth signal — is restructured into a CADF-conformant nestedHostobject (DSP0262 §9.3.3) withid(the former string value) and a newaddressattribute for the client network address. Per spec,hoston aResource(whichInitiatoris) is a structured type;addressis that type’s designated network-address attribute, not a sibling field onInitiator. This is a breaking wire-format change (hostwas a string, is now an object), so the payloadversionfield bumps from"1.0"to"1.1". SIEMs parsinghostas a string must be updated to read it as an object before ingesting v1.1 events.
Context
For feature parity with OpenStack’s pycadf, our Axum/Tonic application
requires CADF-compliant auditing - capturing both perimeter ingress and
business-layer mutations. Our Rust architecture enforces that a security context
cannot be used for policy enforcement before it is fully resolved, using an
externally immutable ValidatedSecurityContext with read-only getters. This
design prevents PII leaks while maintaining zero-trust guarantees.
Wire format: DSP0262 names
The emitted JSON uses the DSP0262 (DMTF CADF) key names, with no format
version change: records are told apart by their shape (eventTime present),
not by a version. Fields the standard has no place for are carried in the
standard extension points instead of new top-level keys:
| Record content | DSP0262 location |
|---|---|
| event time | eventTime |
| record kind | typeURI (http://schemas.dmtf.org/cloud/audit/1.0/event) and eventType (activity) |
| target / initiator / observer type | typeURI (initiator service/security/account/user, observer service/security/keystone) |
outcome_reason | reason: {"reasonType": "keystone", "reasonCode": "<variant>"}, omitted when there is none |
outcome pre-commit record of a fail-closed provider operation | pending |
rejected requests such as 429 | failure, with a client-side reasonCode (TooManyRequests, ClientError) |
| correlation (request) id | tags entry correlation_id:<id> |
seq, boot_session_id, hmac_key_version, version, domain, observer node | attachments entry named integrity (contentType application/json); domain is the initiator’s domain id, unknown when there is none |
OAuth2 client_id and grant_type of a token-endpoint event | optional attachments entry named oauth2 (contentType application/json, content {"client_id", "grant_type"}), present only on those events and covered by the signature; the integrity entry stays first. Consumers must look entries up by name, not by position |
action | one shared style: lowercase [a-z0-9_-] words joined by / (e.g. create, oauth2/refresh_reuse_detected, wasm_plugin/<host_function>); applied to every record by CadfEventPayload::new |
initiator.project_id and initiator.domain_id remain Keystone extension
keys on the initiator (null when absent).
The signature of a DSP0262 record covers the new layout, exactly as written.
seq is serialized as a JSON number; RFC 8785 numbers are
IEEE-754 doubles, so a verifier that canonicalizes it MUST use a 64-bit
integer type (values above 2^53 are not reachable in practice: the counter
is per boot session). Cross-language vectors, including the v1.1 host
cases (host.id, host.address, both, neither — omitted, never null), live
in tests/audit/hmac_vectors.jsonl; tools/audit_vectors.py verify
re-checks them with an independent Python JCS/HMAC implementation.
Decision
We implement a hybrid auditing architecture producing standardized CADF events, dispatched asynchronously to configurable sinks across three phases: Framework, Perimeter (Extractor + Middleware), and Provider (Hooks).
Phase 1: General Audit Framework & CADF Types
Strict Rust representation of the CADF standard and async dispatch machinery.
The CADF Payload: To ensure unsigned events cannot serialize, CadfEvent
wraps a private CadfEventPayload and signature via serde(flatten).
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone)]
pub struct CadfEventPayload {
id: String,
seq: u64,
boot_session_id: String,
hmac_key_version: u64,
version: String,
domain: String,
correlation_id: String,
event_time: String,
action: String, outcome: String,
outcome_reason: Option<String>,
initiator: Initiator,
target: Target, observer: Observer,
}
impl CadfEventPayload {
fn sign(self, dispatcher: &AuditDispatcher) -> CadfEvent {
dispatcher.finalize_event(self)
}
/// Internal test-tooling only — NOT the SIEM verification path.
/// External SIEMs MUST implement verification by: (1) parse received JSON,
/// (2) remove the `signature` key, (3) serialize the remainder in JCS
/// canonical form (RFC 8785), (4) compute HMAC-SHA256 with the key
/// identified by `hmac_key_version`. Cross-language test vectors
/// (tests/audit/hmac_vectors.jsonl) cover this exact path.
fn from_cadf(evt: &CadfEvent) -> Self {
let e = evt.payload();
Self {
id: e.id.clone(), seq: e.seq, boot_session_id: e.boot_session_id.clone(),
hmac_key_version: e.hmac_key_version, version: e.version.clone(),
domain: e.domain.clone(), correlation_id: e.correlation_id.clone(),
event_time: e.event_time.clone(), action: e.action.clone(),
outcome: e.outcome.clone(), outcome_reason: e.outcome_reason.clone(),
initiator: e.initiator.clone(), target: e.target.clone(),
observer: e.observer.clone(), }
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct CadfEvent {
#[serde(flatten)]
event: CadfEventPayload,
signature: String,
}
impl CadfEvent {
pub fn payload(&self) -> &CadfEventPayload { &self.event }
pub fn signature(&self) -> &str { &self.signature }
pub fn correlation_id(&self) -> &str { &self.event.correlation_id }
pub fn id(&self) -> &str { &self.event.id }
pub fn seq(&self) -> u64 { self.event.seq }
pub fn boot_session_id(&self) -> &str { &self.event.boot_session_id }
}
// `host` is a nested CADF `Host` object (DSP0262 §9.3.3), not a bare
// string — `Resource.host` (Initiator is a `Resource`) is spec-defined as a
// structured type with `id`, `address`, `agent`, `platform`. Only `id` and
// `address` are populated; `agent`/`platform` are valid but unused.
#[derive(Serialize, Clone)]
pub struct Host {
/// Pre-auth signal (EC2 access key, federation idp_id). No PII.
/// Content arrives before authentication and is fully attacker-controlled.
/// Sanitization rules (enforced at construction, not by Host itself):
/// EC2 access key — must match /^AKIA[A-Z0-9]{16}$/; rejected otherwise.
/// Federation idp_id (UUID) — passed through sanitize_audit_id().
/// Federation idp_id (non-UUID) — filtered to [a-zA-Z0-9._-], max 64 chars.
/// Any other value — filtered to printable ASCII (0x20–0x7E), max 128 chars.
/// Field is omitted (None) if empty after filtering.
id: Option<String>,
/// Client network address. Parsed via `std::net::IpAddr` and re-rendered
/// through `Display`; anything that doesn't parse as an IP is dropped
/// rather than stored raw. Field is omitted (None) if never set.
address: Option<String>,
}
impl Host {
fn from_id(id: String) -> Self { Self { id: Some(id), address: None } }
pub fn id(&self) -> Option<&str> { self.id.as_deref() }
pub fn address(&self) -> Option<&str> { self.address.as_deref() }
}
#[derive(Serialize, Clone)]
pub struct Initiator {
id: String, project_id: Option<String>, domain_id: Option<String>,
host: Option<Host>,
}
impl Initiator {
fn new(id: String, project_id: Option<String>, domain_id: Option<String>,
host: Option<Host>) -> Self
{ Self { id, project_id, domain_id, host } }
pub fn id(&self) -> &str { &self.id }
pub fn project_id(&self) -> Option<&str> { self.project_id.as_deref() }
pub fn domain_id(&self) -> Option<&str> { self.domain_id.as_deref() }
pub fn host(&self) -> Option<&Host> { self.host.as_ref() }
/// Attach a client IP to `host.address`, sanitized. Preserves any
/// pre-auth `host.id` signal already set.
fn with_address(mut self, address: Option<String>) -> Self { /* ... */ self }
}
#[derive(Serialize, Clone)]
pub struct Target { pub id: String, pub type_uri: String }
#[derive(Serialize, Clone)]
pub struct Observer { pub node_id: String, pub id: String }
}
Principal-based initiator - There is no separate “verified token” type.
After authentication succeeds, build_initiator_from_principal() derives the
Initiator from the authentication result’s PrincipalInfo (immutable
authentication chain, never from token scope). A later scope or authorization
failure is therefore attributed to the known principal, while a failure before
authentication completes yields an all-"unknown" initiator. Internal
(non-request) actors use the system convention system:<component> as the
initiator id (e.g. system:oauth2-key-hook).
Outcome reasons - reason is drawn from the closed OutcomeReason
vocabulary (the sanitized error variant name or a static literal). Free-form
error text, plugin-supplied deny messages and route details never enter a
signed record; they are logged instead.
Request audit context - A request-scoped middleware
(with_audit_request_context) places the resolved client IP and the
correlation id in a task-local AuditRequestContext. Perimeter and provider
emitters read it to stamp initiator.host.address and correlation_id; a
value set explicitly by a handler (e.g. the EC2 access key as host.id) wins.
Host functions invoked inside a WASM guest may not see the task-local and then
use a fresh correlation id.
ID sanitization - Strips non-ASCII, caps at 64 chars. Accepts either
UUID rendering Keystone actually produces: canonical hyphenated, or
Uuid::simple() (no hyphens) — the latter is what every resource ID
minted across the codebase uses (Uuid::new_v4().simple()), so a
strict-canonical-only check (schema v1.0) coerced every real ID to
"unknown":
#![allow(unused)]
fn main() {
fn sanitize_audit_id(id: &str) -> String {
if id.trim().is_empty() { return "unknown".to_string(); }
let cleaned: String = id.chars()
.filter(|c| c.is_ascii_hexdigit() || *c == '-')
.take(64).collect();
if cleaned.is_empty() { return "unknown".to_string(); }
// Canonical: len 36, 4 hyphens at canonical positions, 32 hex digits.
let is_canonical = cleaned.len() == 36
&& cleaned.chars().filter(|c| *c == '-').count() == 4
&& cleaned.chars().filter(|c| c.is_ascii_hexdigit()).count() == 32
&& cleaned.get(8..9) == Some("-")
&& cleaned.get(13..14) == Some("-")
&& cleaned.get(18..19) == Some("-")
&& cleaned.get(23..24) == Some("-");
// Simple: exactly 32 hex digits, no hyphens (Uuid::simple() format).
let is_simple = cleaned.len() == 32 && cleaned.chars().all(|c| c.is_ascii_hexdigit());
if is_canonical || is_simple { cleaned } else { "unknown".to_string() }
}
}
The Audit Dispatcher - Dual-channel QoS with atomic HMAC rotation:
#![allow(unused)]
fn main() {
pub struct AuditDispatcher {
perimeter_sender: mpsc::Sender<CadfEvent>, // 4096, best-effort
critical_sender: mpsc::Sender<CadfEvent>, // 256, fail-closed
node_id: Arc<str>,
hmac_key_and_version: ArcSwap<(Arc<[u8]>, u64)>,
boot_session_id: String,
seq_counter: AtomicU64,
dropped_count: Arc<AtomicU64>,
last_drop_log_time: AtomicU64,
log_baseline: std::time::Instant,
postaudit_dropped_count: Arc<AtomicU64>,
events_total: Arc<AtomicU64>, // total events dispatched (perimeter + critical)
}
impl AuditDispatcher {
// Signs over unsigned payload. HMAC input is the JCS-canonical (RFC 8785)
// UTF-8 JSON of all payload fields with keys in lexicographic order, no
// extra whitespace. Most Option-typed fields serialize as `null` when
// absent. Exception: `Initiator.host` (and, nested within it, `host.id`
// / `host.address`) uses skip_serializing_if and is **omitted entirely**
// (not set to `null`) when absent. SIEMs MUST re-serialize the received
// JSON (minus `signature`) without inserting absent keys. This is the
// sole canonical form for HMAC verification.
fn finalize_event(&self, partial: CadfEventPayload) -> CadfEvent {
let (key, version) = self.hmac_key_and_version.load_full().as_ref();
let completed = CadfEventPayload {
seq: self.seq_counter.fetch_add(1, Ordering::SeqCst),
boot_session_id: self.boot_session_id.clone(),
hmac_key_version: **version, ..partial };
let sig = compute_hmac_sha256(&completed, &**key);
CadfEvent { event: completed, signature: sig }
}
/// Best-effort: drops if full. Floor-rate log: at least once/sec.
pub fn dispatch(&self, event: CadfEvent) {
self.events_total.fetch_add(1, Ordering::Relaxed);
let cid = event.correlation_id().to_string();
if self.perimeter_sender.try_send(event).is_err() {
let count = self.dropped_count.fetch_add(1, Ordering::Relaxed);
let now_us = self.log_baseline.elapsed().as_micros() as u64;
let should_log = (count % 1024) == 0
|| (self.last_drop_log_time.load(Ordering::Relaxed) + 1_000_000)
<= now_us;
if should_log {
self.last_drop_log_time.store(now_us, Ordering::Relaxed);
error!(dropped_count = count, correlation_id = %cid,
"audit channel full, event dropped (best-effort)");
}
}
}
/// Fail-closed: blocks until sent.
pub async fn dispatch_critical(&self, event: CadfEvent)
-> Result<(), AuditChannelDead>
{
self.events_total.fetch_add(1, Ordering::Relaxed);
self.critical_sender.send(event).await.map_err(|_| AuditChannelDead)
}
/// MUST be called from a single serialized context (dedicated key-rotation
/// task). Concurrent invocations produce version collisions (two different
/// keys share the same version), breaking SIEM verification.
pub(crate) fn refresh_hmac_key(&self, new_key: Arc<[u8]>, new_version: u64) {
self.hmac_key_and_version.store(Arc::new((new_key, new_version)));
}
}
}
Spooling & Replay: Workers drain channels to sinks. On shutdown, unsent critical events spool. On startup, HMAC-verified and replayed. Corrupted lines are skipped to recover adjacent valid events; file quarantined at end.
boot_session_id: MUST be a UUIDv4 generated from the OS CSPRNG at
process startup, before any request handling. MUST NOT be derived from a
wall-clock timestamp, PID, or any predictable value. It is never persisted;
its sole purpose is to namespace the seq counter within a single process
lifetime. SIEMs MUST partition seq-gap detection by
(node_id, boot_session_id) to avoid false gap alerts across restarts.
Phase 2: Perimeter Auditing (Ingress & Completion)
Captures access attempts at the boundary.
-
Ingress (
AuthExtractor): The Axum middleware already injects arequest-idheader (UUIDv4). If the client already sent arequest-idheader, the middleware must strictly ignore its value and overwrite it with a fresh UUIDv4 (prevent client-controlled correlation spoofing).correlation_idis derived from this header value, not from the request token. ExtractsInitiatorfrom fully resolvedValidatedSecurityContext, token parse failure:outcome: "failure",Initiatoris all"unknown"(no partial data from untrusted payload). On partial validation failure (token parsed but policy/scope failed): uses the authenticated principal (build_initiator_from_principal) to extract the sanitized initiator. For endpoints with pre-auth identity signals (EC2accesskey, federationidp_id), include non-PII identifiers asinitiator.host.id— these don’t require a validated context and don’t risk PII leakage. The client network address is stamped separately asinitiator.host.addressonce available, resolved via ADR 0022’sresolve_client_ip_from_headersunder its own trusted-proxy configuration ([oslo_middleware]) — notpublic_ingress_peer_addr, which returns the raw, pre-resolution TCP peer (the reverse proxy’s own address whenever Keystone sits behind one) and exists so each trust boundary (rate limiting, auth-plugin dispatch, audit) can apply its own resolution independently, per thecrate::netmodule’s trusted-proxy model. For authenticated requests, theAuthextractor resolves it once and sets it on everyValidatedSecurityContextit builds (viaSecurityContext.peer_addr, mirroring howcorrelation_idis threaded); the login endpoint, which authenticates before anyValidatedSecurityContextexists, resolves and stamps it directly, independently of the raw peer address it separately feeds to rate limiting and auth-plugin dispatch. -
Completion (Middleware):
with_audit_request_contextwraps the request. Authentication layers (Auth, API-key auth) record the authenticated initiator in the request audit context; after the handler returns, the middleware emits one perimeter record with the HTTP status mapped to an outcome (2xx/3xxsuccess,401/403/5xxfailure, other4xxincluding429failurewith a client reason) and a static reason. It applies only to an explicit allowlist of authentication surfaces (/v3|v4/auth/tokens,/v3/ec2tokens,/v4/auth/passkey,/v4/vendordata,/SCIM/v2,POST /v4/k8s_auth/{id}/auth), so ordinary validated-token traffic does not swamp the perimeter channel; and only when the handler did not already emit its own perimeter record, so each request leaves exactly one. An early rejection such as a rate limit therefore still leaves a record. Initiator isunknownwhen authentication never completed.
Error sanitization - Exhaustive match prevents silent PII leakage:
#![allow(unused)]
fn main() {
// Excerpts of `crates/keystone/src/audit.rs`. Both matches have no wildcard
// arm, so a new `KeystoneApiError` / `AuthenticationError` variant does not
// compile until it is classified.
pub fn error_variant_name(error: &KeystoneApiError) -> String {
match error {
KeystoneApiError::Unauthorized { source, .. }
| KeystoneApiError::Forbidden { source, .. } => source
.downcast_ref::<AuthenticationError>()
.map(|e| sanitize_authentication_error(e).to_string())
.unwrap_or_else(|| "Unauthorized".to_string()),
KeystoneApiError::UnauthorizedNoContext => "Unauthorized".to_string(),
KeystoneApiError::NotFound { .. } => "NotFound".to_string(),
KeystoneApiError::Conflict(_) => "Conflict".to_string(),
KeystoneApiError::BadRequest(_) => "BadRequest".to_string(),
KeystoneApiError::InvalidToken => "InvalidToken".to_string(),
KeystoneApiError::TooManyRequests { .. } => "TooManyRequests".to_string(),
KeystoneApiError::ServiceUnavailable(_) => "ServiceUnavailable".to_string(),
// ... every remaining variant (about two dozen) is listed explicitly.
}
}
pub fn sanitize_authentication_error(e: &AuthenticationError) -> &'static str {
match e {
AuthenticationError::UserDisabled(_) => "UserDisabled",
AuthenticationError::UserLocked(_) => "UserLocked",
AuthenticationError::UserNameOrPasswordWrong => "UserNameOrPasswordWrong",
AuthenticationError::Ec2SignatureInvalid => "Ec2SignatureInvalid",
AuthenticationError::Provider { source, .. } => {
extract_provider_name(source.as_ref()).unwrap_or("ProviderError")
}
// ... all ~40 variants map to stable literals.
}
}
/// Type-only dispatch: no provider error string content is used. Guarantees
/// PII in third-party provider errors (emails, tokens) never reaches audit.
fn extract_provider_name(source: &Box<dyn std::error::Error>)
-> Option<&'static str>
{
if source.is::<identity::IdentityProviderError>() { Some("Identity") }
else if source.is::<catalog::CatalogProviderError>() { Some("Catalog") }
else if source.is::<role::RoleProviderError>() { Some("Role") }
else if source.is::<assignment::AssignmentProviderError>() { Some("Assignment") }
else { None }
}
}
Semantic action mapping - Hardcodes v3/v4 paths, sanitizes
Operation::Other:
#![allow(unused)]
fn main() {
fn map_event_to_action(event: &Event) -> String {
match &event.operation {
Operation::Create => "create".to_string(),
Operation::Update => "update".to_string(),
Operation::Delete => "delete".to_string(),
Operation::Disable => "disable".to_string(),
Operation::Enable => "enable".to_string(),
Operation::Authenticate => "authenticate".to_string(),
Operation::Revoke => "revoke".to_string(),
Operation::Other(action) => {
// Sanitize: ASCII alphanumeric + /, -, _; cap 64 chars; reject empty.
let s: String = action.chars()
.filter(|c| c.is_ascii_alphanumeric()
|| *c == '-' || *c == '_' || *c == '/')
.take(64)
.collect();
if s.is_empty() { "unknown".to_string() } else { s }
}
}
}
}
Phase 3: Provider Auditing via Context-Aware Hooks
ProviderHooks (on_event) is fire-and-forget without context. Instead,
AuditHook receives context and outcome, dispatched inline with fail-closed
semantics. Reentrancy prevented via tokio::task_local!.
#![allow(unused)]
fn main() {
pub trait AuditHook: Send + Sync {
async fn on_auditable_event(&self, ctx: &ValidatedSecurityContext,
event: &Event, outcome: &AuditOutcome) -> Result<(), AuditDispatchError>;
}
pub enum AuditOutcome { Attempt, Success, Failure { reason: String } }
/// Sanitize hook error to stable literal. Prevents {:?} debug formatting
/// from leaking type names or internal diagnostics into CADF outcomes.
/// Hook errors only abort the provider op; they never flow into outcome_reason.
pub enum AuditDispatchError {
DispatcherDead,
HookFailed { description: &'static str },
Reentered,
}
impl EventDispatcher {
/// Fail-closed pre-audit: any hook error aborts the provider operation.
/// Collects hook errors (except DispatcherDead short-circuits).
pub async fn emit_critical(&self, ctx: &ValidatedSecurityContext,
event: &Event, outcome: &AuditOutcome) -> Result<(), AuditDispatchError>
{
let is_reentered = EMIT_CRITICAL_RECURSION.try_with(|v| *v).unwrap_or(false);
if is_reentered { return Err(AuditDispatchError::Reentered); }
EMIT_CRITICAL_RECURSION.scope(true, async move {
let audit = self.audit_hooks.lock().await.values().cloned().collect::<Vec<_>>();
let mut error_count = 0u64;
for hook in &audit {
match hook.on_auditable_event(ctx, event, outcome).await {
Err(AuditDispatchError::DispatcherDead) =>
return Err(AuditDispatchError::DispatcherDead),
Err(AuditDispatchError::HookFailed { .. }) => error_count += 1,
Err(AuditDispatchError::Reentered) => error_count += 1,
Ok(()) => {}
}
}
if error_count > 0 {
return Err(AuditDispatchError::HookFailed {
description: "hook execution failed",
});
}
// ... fire-and-forget regular hooks ...
Ok(())
}).await
}
}
}
Audit-Before-Commit (Fail-Closed Transaction Safety):
#![allow(unused)]
fn main() {
// Shape of the macro (`crates/core/src/events.rs`); see its rustdoc for the
// full contract.
audited_op! {
dispatcher: &self.events,
ctx: vsc, // &ValidatedSecurityContext
event: Event::new(Operation::Delete, EventPayload::User { id }),
operation: self.backend.delete_user(state, id),
on_audit_error: |e| ProviderError::AuditDispatchFailed { source: e },
// optional: reason: |e| "StaticReason"
}
}
Behaviour: (1) emit the Attempt record on the critical channel and return
on_audit_error(..) without running the operation if that fails (fail-closed);
(2) run the operation; (3) emit Success, or Failure whose reason is a
&'static str variant name from the error’s strum::IntoStaticStr derive
(never formatted from error data). If the post-audit record cannot be queued, an
ERROR log is written and keystone_audit_postaudit_dropped_total is
incremented.
Coverage: every state-changing provider method is wrapped in audited_op!
(through audited_if_ctx!, which runs the operation unaudited-but-emitted when
the caller is internal, e.g. a janitor or a hook, and has no principal to
attribute it to). That includes API keys, OAuth2 clients and signing keys,
token revocation (DELETE /v3/auth/tokens), domain configuration, ID
mappings, dynamic plugin identities, SCIM index entries and the credential
bulk deletes. Payloads carry IDs only, never secrets or option values. The
unit test audit_coverage::every_mutating_provider_method_is_audited_or_allow_listed
walks every provider service.rs and fails for a mutating method that has
neither call site nor an ALLOW_LIST entry with a reason, so a new provider
cannot ship unaudited by accident.
CADF Hook: Single CadfAuditHook translates events to CADF, signs via
CadfEventPayload::sign(), dispatches via dispatch_critical(). Wired at
startup: state.event_dispatcher.subscribe_audit(CadfAuditHook).await.
Security Compliance vs. PII Requirements
- Data Minimization:
Initiatorhas only UUIDs. Human-readable fields (usernames, emails) are excluded by design. - PII Redaction:
username,display_name,email_address,project_name,domain_nameexcluded. Future fields require opaque wrappers. - Outcome Isolation:
outcome_reasonlimited to sanitized variant name. - HMAC Signing:
CadfEventwraps(CadfEventPayload, signature)viaserde(flatten). Private fields prevent unsigned construction. Per-node signing key derived via:
TheHKDF(salt="keystone-audit-hmac-salt-v1", IKM=KEK, info="keystone-audit-hmac-v1:{node_id_utf8}", L=32)node_idsuffix ensures each node holds a distinct signing key; a compromised node cannot forge audit records attributed to other nodes. This aligns with ADR 0016-v2 §3.1 (which usesnode_id_u64_befor Raft nodes; here we use the UTF-8 encoding of the string node ID). Full HKDF (Extract-then-Expand with a fixed non-empty salt) is used, rather than the Expand-only form, because it is the flow the FIPS 140-3 validated module documents as an approved key derivation. Thecadfcrate has afipsfeature that runs HMAC-SHA256, HKDF and key generation on that module. Key+version asArcSwap<(Arc<[u8]>, u64)>for atomic rotation (ADR 0016-v2 §6.2). HMAC input is the JCS-canonical (RFC 8785) serialization of the payload (all fields, lexicographically sorted keys, compact, null fields included). - HMAC Key Retention: The KEK store MUST retain all HMAC key versions for
at least
max(spool_drain_timeout + SIEM_lag_budget, 24h). Key versions are monotonically increasing and permanent — never reused. The version number is supplied by the key-rotation task (not derived byrefresh_hmac_keyitself) to prevent version collisions under concurrent rotation attempts. SIEMs MUST cache all key versions seen and MUST NOT delete them without operator confirmation. - Spool Integrity: Corrupted/tampered lines skipped (recovery-first).
Per-node spool path (
audit-spool-{node_id}.jsonl) eliminates shared-file races; advisory lock as secondary guard. Because the HMAC key is per-node (node_id is bound into the key derivation), the SIEM can reject events whoseobserver.node_iddoes not match the key used to verify their signature; mismatches MUST be quarantined as tamper indicators, not silently accepted. - Delivery Guarantee: At-least-once delivery. SIEMs must deduplicate on
CadfEvent.id(uniquenode_id:uuid). - Attempt Reconciliation: SIEM treats an
Attemptwith no correspondingSuccess/Failurewithin 300s asoutcome: unknownand triggers a warning alert. Loki query:sum_over_time({app="keystone"} | cadf_outcome="pending" [10m]) - sum_over_time({app="keystone"} | cadf_outcome=~"success|failure" [10m]) > 0 - Authenticated Principal Boundary: the initiator of a post-authentication
failure comes only from the authentication result’s principal
(
build_initiator_from_principal). Partial context failure = authorization issue, not crypto issue. - Provider Error Sanitization:
extract_provider_nameuses type-only dispatch (is::<T>()). No error string content used.
Observability
The audit counters are exported as Prometheus counters (not gauges), next
to gauges for the queue depth, spool size and signing key version, using the
shared openstack-keystone-metrics primitives (ADR 0031):
| Metric | Type | Meaning |
|---|---|---|
keystone_audit_events_total | counter | Events accepted into a channel (drops excluded) |
keystone_audit_dropped_total | counter | Perimeter events dropped because the channel was full |
keystone_audit_postaudit_dropped_total | counter | Post-audit outcomes that could not be recorded |
keystone_audit_channel_depth{channel} | gauge | Events queued for the spool writer (perimeter, critical) |
keystone_audit_hmac_key_version | gauge | HMAC key version currently signing events |
keystone_audit_spool_bytes | gauge | Live spool plus sealed segments on disk |
keystone_audit_spool_write_failures_total | counter | Events the writer failed to append (lost) |
keystone_audit_spool_quarantined_total | counter | Segments quarantined for tampered or unparsable lines |
keystone_audit_spool_verified_total{result} | counter | Lines checked at startup (verified, invalid) |
keystone_audit_shipped_events_total{result} | counter | Events handed to the sink (shipped, skipped) |
keystone_audit_sink_errors_total | counter | Failed batch deliveries to the sink |
keystone_audit_spool_retention_deleted_total | counter | Sealed segments deleted unacknowledged by the size, age or count limits |
Offered load on the perimeter channel is events + dropped, so the drop ratio
is dropped / (events + dropped). The rules below live in
deploy/prometheus/alert_rules.yaml, together with alerts for spool write
failures, quarantined segments, a failing sink and a backed-up critical
channel:
groups:
- name: keystone_audit
rules:
- alert: KeystoneAuditDropsVolumetric
expr: |
rate(keystone_audit_dropped_total[5m]) > 100 and
rate(keystone_audit_dropped_total[5m]) /
(rate(keystone_audit_events_total[5m]) + rate(keystone_audit_dropped_total[5m])) > 0.05
for: 2m
labels: { severity: critical }
annotations:
summary: "Audit drops >100/s (>5% of perimeter events)"
description:
"Possible volumetric attack. Check rate limiting (ADR 0022)."
- alert: KeystoneAuditPostauditDrops
expr: |
increase(keystone_audit_postaudit_dropped_total[5m]) > 0 and
rate(keystone_audit_events_total[5m]) > 0
for: 1m
labels: { severity: critical }
annotations:
summary: "Post-audit outcome record lost after DB commit"
description:
"The outcome record (Success/Failure) for a high-criticality op
(disable_user, delete_credential) was dropped. The pre-audit Attempt
exists, but the final outcome is lost. Compensating local log
entries (structured JSONL) should be independently shipped to the
SIEM for dual-delivery."
Implementation status
| Area | Status |
|---|---|
Framework: AuditDispatcher, HMAC signing, spool, replay | Implemented |
| Spool segments, quarantine of tampered files, writer lock | Implemented |
| Downstream sink and segment acknowledgement | Implemented (stdout sink; a segment is deleted only after the sink accepted it; a network sink is tracked separately) |
Shutdown drain (spool_drain_timeout_secs) | Implemented |
Metrics (keystone_audit_*) and alert rules | Implemented; counters, except the gauges named in the metric list |
| Perimeter events: login handlers | Implemented (token, EC2, OAuth2 token, federation JWT/OIDC) |
| Perimeter completion record for the other authentication surfaces | Implemented by the request middleware (path allowlist) |
| Ingress record for every request | Opt-in: [audit] perimeter_all_requests = true adds the completion record to every request (default false, to keep the perimeter channel bounded). Not a separate event from the Auth extractor |
Provider auditing: audited_op! / audited_if_ctx! | Implemented, enforced by a coverage test over the provider services |
| Initiator from the authenticated principal; request audit context | Implemented |
| Wire format | DSP0262 key names, integrity fields in an attachments entry (see above); schema version 1.1 |
| Signing-key rotation | Key versions are stamped and old versions verify; operator-driven rotation is not automated |
Phase numbers in code comments (“Phase 4” metrics, “Phase 5.x” vectors and integration tests) refer to follow-up work after the three phases above; it is described in the Observability section and covered by the test suites.
Related ADRs
- 0016-v2: HMAC key from KEK. KEK rotation calls
refresh_hmac_key(). - 0017:
ValidatedSecurityContextin hooks.correlation_id(),verified_token()for partial context. - 0020: Mapping engine errors sanitized via
error_variant_name(). - 0022: Rate-limiting. 429 produces
outcome: "failure"with reasonTooManyRequests. Audit drop alerts correlated with rate limiter health.
Alternatives
- Provider wrapper traits: Rejected — 30+ enum expansions.
AuditHookis a single subscription point. - Mutable context propagation: Rejected — Rust ownership enforces integrity.
- Single-channel dispatch: Rejected — dual channels provide QoS isolation.
- Post-serialization signing: Rejected — two-phase builder prevents unsigned-in-channel window.
Accepted Risk: Millisecond Durability Gap
dispatch_critical() returns Ok(()) after placing the signed event into the
in-memory mpsc channel (256 depth). If the node hard-crashes (power loss,
kernel panic) before the background worker drains the event to the spool file,
that signed event is lost from RAM. The DB transaction that triggered the event
has already committed, so the audit trail has a gap.
Why synchronous fsync is rejected: Adds ~10-50ms latency per critical provider operation. At Keystone scale (thousands of ops/sec), this causes SLO violation. A per-event WAL is equally expensive. The in-memory channel provides ordering without blocking the provider call path.
Mitigations: Graceful shutdown (SIGTERM) drains the full channel (10s budget) before exit — zero events lost. Spool replay covers process restarts via graceful shutdown handlers. Compensating local logs for post-audit drops provide dual delivery for high-criticality ops.
Risk acceptance: The millisecond crash window trades an extremely rare single-event loss for guaranteed sub-1ms provider latency. This aligns with OpenStack’s design philosophy: audit is advisory for SIEM compliance, not a hard transactional requirement.
Relationship to the Raft Storage Audit (#1378)
The distributed storage layer (ADR 0016-v2 §3.1) keeps its own audit pipeline
in crates/storage: AuditForwarder signs AuditRecords with an HMAC key
derived from the DEK epoch and appends them to a bounded, fsynced spool. That
leaves two producers next to the CADF pipeline described here. The decision is
to keep the two signing schemes and record shapes separate, and to share the
delivery path.
Why not fold the forwarder into the cadf spool:
- Different trust root. The storage key is derived from the DEK, rotates
with every DEK epoch swap on every node and is bound to the HSM/KEK
provider; the CADF key comes from the audit keyring
(
[audit] hmac_kek_file) and rotates on its own schedule. Merging them would either tie CADF signing to the storage key lifecycle or weaken the storage one. - Different record shape. Storage records are
{"record", "key_version", "hmac"}lines keyed bydek_version, not CADF events; a SIEM already verifies them with the documented procedure. Re-shaping them is a wire-format break for no integrity gain. - Dependency direction.
crates/storageis deliberately free of the keystone service crates; the audit crate depends oncore-types.
What is shared instead. The segment shipper is generic over the producer:
with [audit] sink configured, run_raw_segment_shipper ships the sealed
raft-audit-<node_id>.jsonl.seg-* segments from the storage audit spool
(audit_spool_dir, default <path>/audit-spool) through the same
AuditSink, verbatim, and deletes each segment once the sink acknowledged it.
The live file is never read. Sinks carry these lines through
AuditSink::write_lines (stdout: the raw line; syslog: the line as MSG with
MSGID = raft-audit). The storage spool writer drops segments the shipper
removed from its size accounting, so acknowledged data no longer counts against
audit_max_spool_bytes. One sink configuration, one delivery guarantee
(at-least-once, deduplicate on the record), two signing schemes.
Open follow-up: converging the two key hierarchies and record formats (one derivation, one CADF shape for storage-side events) remains a possible future change; it needs a KMS-side derive for PKCS#11/TPM and a migration for existing storage audit consumers, and is out of scope here.
Consequences
- Security: Two-event perimeter + fail-closed provider audit ensures
complete coverage. Post-audit uses
emit_criticalwith compensating local log fallback for dual-delivery.KeystoneAuditPostauditDropsalert is critical. - Performance: Dual channels isolate perimeter (4096, best-effort) from critical (256, fail-closed). Not a substitute for rate limiting (ADR 0022).
- Correctness: Correlation IDs link perimeter through provider events.
- Integrity: Two-phase builder, sanitized error names, monotonic seq.
- Shutdown: 10s drain timeout, disk spool. Up to 4096 perimeter events may be lost. Corrupted spools quarantined for forensic triage.