Skip to content

Commit 93300be

Browse files
authored
fix(audit): warn when storage parser falls back to Custom for framework namespace (#24)
All four storage backends' AuditEventKind parsers ended with a Custom catch-all that quietly wrapped any unknown event-kind string. For genuinely user-defined custom events this is correct, but for the framework-owned namespaces (auth.*, http.*, account.*, config.*) it silently swallows version-skew bugs — a downstream emitter on a newer acton-service writes a new kind, an older reader returns Custom("auth.token.missing"), and any consumer matching on the typed variant misses the event with no signal. Factor the catch-all logic into pub(crate) helpers in audit/storage/mod.rs: - looks_like_framework_kind(s) — true for strings beginning with one of the four framework-owned prefixes. - parse_custom_kind(s) — strips the "custom." prefix (preserving the prior round-trip behavior for user-defined kinds) and emits a tracing::warn! when looks_like_framework_kind is true, naming the exact stored string so operators can grep for it. All four parser catch-alls route through parse_custom_kind. Add unit tests covering both helpers across the framework-namespace, custom- namespace, and bare-user-string cases. Closes #20
1 parent e6749ee commit 93300be

5 files changed

Lines changed: 70 additions & 16 deletions

File tree

acton-service/src/audit/storage/clickhouse_impl.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,7 @@ impl From<AuditQueryRow> for AuditEvent {
176176
"config.drift_detected" => AuditEventKind::ConfigDriftDetected,
177177
"http.request" => AuditEventKind::HttpRequest,
178178
"http.request.denied" => AuditEventKind::HttpRequestDenied,
179-
other => {
180-
let name = other.strip_prefix("custom.").unwrap_or(other);
181-
AuditEventKind::Custom(name.to_string())
182-
}
179+
other => AuditEventKind::Custom(super::parse_custom_kind(other)),
183180
};
184181

185182
let severity = match row.severity {

acton-service/src/audit/storage/mod.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,72 @@ pub mod surrealdb_impl;
2828
#[cfg(feature = "clickhouse")]
2929
pub mod clickhouse_impl;
3030

31+
/// Returns true if the stored event-kind string looks like a framework-owned
32+
/// kind (`auth.*`, `http.*`, `account.*`, `config.*`) that should have been
33+
/// recognized by the parser. Used by parser catch-alls to detect likely
34+
/// version skew between an emitter and a reader.
35+
pub(crate) fn looks_like_framework_kind(s: &str) -> bool {
36+
s.starts_with("auth.")
37+
|| s.starts_with("http.")
38+
|| s.starts_with("account.")
39+
|| s.starts_with("config.")
40+
}
41+
42+
/// Helper for storage-backend parser catch-alls.
43+
///
44+
/// Strips the `custom.` prefix when present (so user-defined custom events
45+
/// round-trip cleanly) and emits a `tracing::warn!` when the input looks
46+
/// like a framework-owned kind that no parser arm matched — i.e. the
47+
/// emitter is on a newer version than this reader.
48+
pub(crate) fn parse_custom_kind(s: &str) -> String {
49+
if looks_like_framework_kind(s) {
50+
tracing::warn!(
51+
stored_kind = %s,
52+
"unrecognized framework audit event kind — falling back to Custom; likely version skew between emitter and reader"
53+
);
54+
}
55+
s.strip_prefix("custom.").unwrap_or(s).to_string()
56+
}
57+
58+
#[cfg(test)]
59+
mod helper_tests {
60+
use super::{looks_like_framework_kind, parse_custom_kind};
61+
62+
#[test]
63+
fn framework_prefixes_detected() {
64+
assert!(looks_like_framework_kind("auth.token.invalid"));
65+
assert!(looks_like_framework_kind("http.request.denied"));
66+
assert!(looks_like_framework_kind("account.created"));
67+
assert!(looks_like_framework_kind("config.drift_detected"));
68+
}
69+
70+
#[test]
71+
fn non_framework_prefixes_ignored() {
72+
assert!(!looks_like_framework_kind("custom.user.exported"));
73+
assert!(!looks_like_framework_kind("user.signed_up"));
74+
assert!(!looks_like_framework_kind("billing.invoice.paid"));
75+
assert!(!looks_like_framework_kind(""));
76+
}
77+
78+
#[test]
79+
fn parse_custom_strips_custom_prefix() {
80+
assert_eq!(parse_custom_kind("custom.user.exported"), "user.exported");
81+
}
82+
83+
#[test]
84+
fn parse_custom_preserves_unprefixed_user_strings() {
85+
assert_eq!(parse_custom_kind("billing.invoice.paid"), "billing.invoice.paid");
86+
}
87+
88+
#[test]
89+
fn parse_custom_passes_through_framework_strings_for_visibility() {
90+
// The warn fires (verified manually / via tracing subscribers in
91+
// integration tests); we assert the returned string preserves the
92+
// original so operators can grep for it in their event store.
93+
assert_eq!(parse_custom_kind("auth.token.invalid"), "auth.token.invalid");
94+
}
95+
}
96+
3197
/// Trait for audit event persistence backends
3298
///
3399
/// Implementations MUST enforce append-only semantics at the database level

acton-service/src/audit/storage/pg.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -305,10 +305,7 @@ impl From<AuditEventRow> for AuditEvent {
305305
"config.drift_detected" => AuditEventKind::ConfigDriftDetected,
306306
"http.request" => AuditEventKind::HttpRequest,
307307
"http.request.denied" => AuditEventKind::HttpRequestDenied,
308-
other => {
309-
let name = other.strip_prefix("custom.").unwrap_or(other);
310-
AuditEventKind::Custom(name.to_string())
311-
}
308+
other => AuditEventKind::Custom(super::parse_custom_kind(other)),
312309
};
313310

314311
let severity = match row.severity {

acton-service/src/audit/storage/surrealdb_impl.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -406,10 +406,7 @@ fn parse_event_kind(s: &str) -> AuditEventKind {
406406
"config.drift_detected" => AuditEventKind::ConfigDriftDetected,
407407
"http.request" => AuditEventKind::HttpRequest,
408408
"http.request.denied" => AuditEventKind::HttpRequestDenied,
409-
other => {
410-
let name = other.strip_prefix("custom.").unwrap_or(other);
411-
AuditEventKind::Custom(name.to_string())
412-
}
409+
other => AuditEventKind::Custom(super::parse_custom_kind(other)),
413410
}
414411
}
415412

acton-service/src/audit/storage/turso.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -398,10 +398,7 @@ fn parse_event_kind(s: &str) -> AuditEventKind {
398398
"config.drift_detected" => AuditEventKind::ConfigDriftDetected,
399399
"http.request" => AuditEventKind::HttpRequest,
400400
"http.request.denied" => AuditEventKind::HttpRequestDenied,
401-
other => {
402-
let name = other.strip_prefix("custom.").unwrap_or(other);
403-
AuditEventKind::Custom(name.to_string())
404-
}
401+
other => AuditEventKind::Custom(super::parse_custom_kind(other)),
405402
}
406403
}
407404

0 commit comments

Comments
 (0)