fraiseql_auth/audit/logger.rs
1//! Audit logging for security-critical authentication operations.
2//!
3//! Tracks all secret access, authentication events, and security decisions.
4//! See the [`AuditLogger`] trait and [`StructuredAuditLogger`] for usage.
5// # Bounds Documentation
6//
7// This module enforces strict size bounds on all audit log entries to prevent
8// memory exhaustion attacks and ensure predictable performance.
9//
10// ## Field Size Limits
11//
12// | Field | Max Size | Reason |
13// |-------|----------|--------|
14// | subject (user ID) | 256 bytes | User IDs rarely exceed this; prevents allocation bloat |
15// | operation | 50 bytes | Fixed set of operations (validate, create, refresh, revoke, etc.) |
16// | error_message | 1 KB (1024 bytes) | Error messages should be brief; prevents log spam |
17// | context | 2 KB (2048 bytes) | Additional context data; rarely needed for detailed info |
18// | **Total per entry** | **~4 KB** | Reasonable memory footprint for audit trail |
19//
20// ## In-Memory Bounds
21//
22// - **Maximum audit entries in memory**: 10,000 entries (safe for servers with 2GB+ RAM)
23// - **Memory per entry**: ~4 KB (structured data + strings)
24// - **Total memory for full buffer**: ~40 MB (acceptable overhead)
25//
26// ## Thread Safety
27//
28// All audit logger implementations MUST be `Send + Sync` and handle concurrent
29// access safely. The `OnceLock` pattern for global logger ensures thread-safe
30// initialization.
31//
32// ## Production Recommendations
33//
34// 1. **Database-Backed Logging**: Deploy with database-backed audit logger for production to avoid
35// memory limits entirely.
36// 2. **Retention Policies**: Implement automated cleanup of old entries in in-memory loggers (e.g.,
37// entries older than 24 hours).
38// 3. **Sampling**: For high-throughput environments, consider sampling less critical events.
39// 4. **Monitoring**: Alert if audit log buffer reaches 80% capacity.
40//
41// See also: `crate::security::ComplianceAuditLogger` for database-backed logging.
42
43use std::sync::Arc;
44
45use serde::{Deserialize, Serialize};
46use tracing::{info, warn};
47
48/// Bounds constants for audit log entries
49pub mod bounds {
50 /// Maximum length for subject (user ID) field
51 pub const MAX_SUBJECT_LEN: usize = 256;
52
53 /// Maximum length for operation field
54 pub const MAX_OPERATION_LEN: usize = 50;
55
56 /// Maximum length for error message field
57 pub const MAX_ERROR_MESSAGE_LEN: usize = 1024;
58
59 /// Maximum length for context field
60 pub const MAX_CONTEXT_LEN: usize = 2048;
61
62 /// Maximum number of audit entries to keep in memory
63 pub const MAX_ENTRIES_IN_MEMORY: usize = 10_000;
64
65 /// Estimated memory per entry (used for capacity planning)
66 pub const BYTES_PER_ENTRY: usize = 4096;
67}
68
69/// Discriminates the kind of security event recorded in an [`AuditEntry`].
70///
71/// Each variant maps directly to one of the operations performed by the auth layer.
72/// String representations (via [`AuditEventType::as_str`]) are stable across releases
73/// and are the values written to log sinks and compliance audit trails.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[non_exhaustive]
76pub enum AuditEventType {
77 /// A JWT was checked for validity (signature, expiry, claims).
78 JwtValidation,
79 /// A JWT access token was refreshed using a refresh token.
80 JwtRefresh,
81 /// OIDC client credentials were accessed from the secret store.
82 OidcCredentialAccess,
83 /// An authorization code was exchanged for OIDC tokens.
84 OidcTokenExchange,
85 /// A new session token pair (access + refresh) was issued.
86 SessionTokenCreated,
87 /// A session token was validated against the session store.
88 SessionTokenValidation,
89 /// A session token was explicitly revoked (logout).
90 SessionTokenRevoked,
91 /// An OAuth CSRF state token was generated for a new authorization flow.
92 CsrfStateGenerated,
93 /// An incoming OAuth callback's `state` parameter was validated.
94 CsrfStateValidated,
95 /// An OAuth authorization flow was initiated (`/auth/start`).
96 OauthStart,
97 /// The OAuth provider redirected back (`/auth/callback`).
98 OauthCallback,
99 /// An authentication flow completed successfully.
100 AuthSuccess,
101 /// An authentication attempt failed.
102 AuthFailure,
103}
104
105impl AuditEventType {
106 /// Return a stable, lowercase snake_case string representation of this event type.
107 ///
108 /// These strings are written to log sinks and compliance systems and will not
109 /// change between releases.
110 pub const fn as_str(&self) -> &'static str {
111 match self {
112 AuditEventType::JwtValidation => "jwt_validation",
113 AuditEventType::JwtRefresh => "jwt_refresh",
114 AuditEventType::OidcCredentialAccess => "oidc_credential_access",
115 AuditEventType::OidcTokenExchange => "oidc_token_exchange",
116 AuditEventType::SessionTokenCreated => "session_token_created",
117 AuditEventType::SessionTokenValidation => "session_token_validation",
118 AuditEventType::SessionTokenRevoked => "session_token_revoked",
119 AuditEventType::CsrfStateGenerated => "csrf_state_generated",
120 AuditEventType::CsrfStateValidated => "csrf_state_validated",
121 AuditEventType::OauthStart => "oauth_start",
122 AuditEventType::OauthCallback => "oauth_callback",
123 AuditEventType::AuthSuccess => "auth_success",
124 AuditEventType::AuthFailure => "auth_failure",
125 }
126 }
127}
128
129/// Classifies the secret credential that was accessed or operated on.
130///
131/// Used in [`AuditEntry`] to identify the category of credential involved in a
132/// security event. This enables compliance queries such as "show all events
133/// involving client secrets" or "which refresh tokens were revoked today".
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[non_exhaustive]
136pub enum SecretType {
137 /// A JWT access token (short-lived bearer credential).
138 JwtToken,
139 /// A session token issued by FraiseQL's session store.
140 SessionToken,
141 /// An OAuth 2.0 client secret used for provider authentication.
142 ClientSecret,
143 /// A long-lived refresh token used to obtain new access tokens.
144 RefreshToken,
145 /// A one-time authorization code returned by the OAuth provider.
146 AuthorizationCode,
147 /// An OAuth `state` token used to carry CSRF and flow metadata.
148 StateToken,
149 /// A CSRF token used to bind a user-agent session to an OAuth flow.
150 CsrfToken,
151}
152
153impl SecretType {
154 /// Return a stable, lowercase snake_case string representation of this secret type.
155 ///
156 /// Written to log sinks and compliance systems; will not change between releases.
157 pub const fn as_str(&self) -> &'static str {
158 match self {
159 SecretType::JwtToken => "jwt_token",
160 SecretType::SessionToken => "session_token",
161 SecretType::ClientSecret => "client_secret",
162 SecretType::RefreshToken => "refresh_token",
163 SecretType::AuthorizationCode => "authorization_code",
164 SecretType::StateToken => "state_token",
165 SecretType::CsrfToken => "csrf_token",
166 }
167 }
168}
169
170/// Audit log entry
171///
172/// # Size Bounds
173///
174/// To prevent memory exhaustion and ensure predictable performance, each field
175/// is bounded in size:
176///
177/// - `subject`: Max 256 bytes (see `bounds::MAX_SUBJECT_LEN`)
178/// - `operation`: Max 50 bytes (see `bounds::MAX_OPERATION_LEN`)
179/// - `error_message`: Max 1 KB (see `bounds::MAX_ERROR_MESSAGE_LEN`)
180/// - `context`: Max 2 KB (see `bounds::MAX_CONTEXT_LEN`)
181/// - **Total per entry**: ~4 KB
182///
183/// # Thread Safety
184///
185/// This struct is immutable once created and `Send + Sync`, making it safe to
186/// pass between threads. Audit loggers that implement `AuditLogger` trait are
187/// responsible for thread-safe storage.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct AuditEntry {
190 /// Event type (jwt_validation, oauth_callback, etc.)
191 pub event_type: AuditEventType,
192 /// Type of secret accessed (jwt_token, session_token, etc.)
193 pub secret_type: SecretType,
194 /// Subject (user ID, service account, etc.) - None for anonymous
195 /// Max 256 bytes per `bounds::MAX_SUBJECT_LEN`
196 pub subject: Option<String>,
197 /// Operation performed (validate, create, revoke, etc.)
198 /// Max 50 bytes per `bounds::MAX_OPERATION_LEN`
199 pub operation: String,
200 /// Whether the operation succeeded
201 pub success: bool,
202 /// Error message if operation failed (user-safe message)
203 /// Max 1 KB per `bounds::MAX_ERROR_MESSAGE_LEN`
204 pub error_message: Option<String>,
205 /// Additional context
206 /// Max 2 KB per `bounds::MAX_CONTEXT_LEN`
207 pub context: Option<String>,
208 /// HMAC-SHA256 chain hash for tamper detection (64 hex chars).
209 ///
210 /// Each entry's hash depends on all previous entries, making retroactive
211 /// tampering detectable. `None` when tamper-evident logging is disabled.
212 /// Verify with [`crate::audit::chain::verify_chain`].
213 pub chain_hash: Option<String>,
214}
215
216/// Audit logger trait - allows different implementations (structured logs, database, syslog, etc.)
217///
218/// # Implementation Requirements
219///
220/// - **Thread Safety**: All implementations must be `Send + Sync` and safe for concurrent access
221/// from multiple threads.
222/// - **Bounds Enforcement**: Implementations MAY enforce the bounds defined in this module, or
223/// delegate to a database backend that handles large-scale logging.
224/// - **Availability**: Should not block request handling; consider async/buffered implementations
225/// for performance.
226/// - **Error Handling**: Should never panic; swallow errors and log them via tracing instead.
227///
228/// # Memory Considerations
229///
230/// - **In-memory implementations**: Should limit to `bounds::MAX_ENTRIES_IN_MEMORY` to prevent
231/// unbounded growth.
232/// - **Production deployments**: Should use database-backed implementations
233/// (`ComplianceAuditLogger`) for scalability and retention.
234pub trait AuditLogger: Send + Sync {
235 /// Log an audit entry
236 ///
237 /// Implementations should ensure:
238 /// - No panics (errors logged via tracing)
239 /// - Thread-safe access to backing storage
240 /// - Bounded memory usage (for in-memory implementations)
241 fn log_entry(&self, entry: AuditEntry);
242
243 /// Convenience method for successful operations
244 fn log_success(
245 &self,
246 event_type: AuditEventType,
247 secret_type: SecretType,
248 subject: Option<String>,
249 operation: &str,
250 ) {
251 self.log_entry(AuditEntry {
252 event_type,
253 secret_type,
254 subject,
255 operation: operation.to_string(),
256 success: true,
257 error_message: None,
258 context: None,
259 chain_hash: None,
260 });
261 }
262
263 /// Convenience method for failed operations
264 fn log_failure(
265 &self,
266 event_type: AuditEventType,
267 secret_type: SecretType,
268 subject: Option<String>,
269 operation: &str,
270 error: &str,
271 ) {
272 self.log_entry(AuditEntry {
273 event_type,
274 secret_type,
275 subject,
276 operation: operation.to_string(),
277 success: false,
278 error_message: Some(error.to_string()),
279 context: None,
280 chain_hash: None,
281 });
282 }
283}
284
285/// Audit logger that emits structured log records via [`tracing`].
286///
287/// Successful operations are logged at `INFO` level; failures at `WARN` level.
288/// All fields of the [`AuditEntry`] are included as tracing fields so that
289/// log aggregators (Loki, Elasticsearch, Splunk, etc.) can filter and query them.
290///
291/// This is the default logger used when [`init_audit_logger`] is never called.
292/// For production compliance requirements, consider a database-backed logger.
293pub struct StructuredAuditLogger;
294
295impl StructuredAuditLogger {
296 /// Create a new `StructuredAuditLogger`.
297 pub const fn new() -> Self {
298 Self
299 }
300}
301
302impl Default for StructuredAuditLogger {
303 fn default() -> Self {
304 Self::new()
305 }
306}
307
308impl AuditLogger for StructuredAuditLogger {
309 fn log_entry(&self, entry: AuditEntry) {
310 if entry.success {
311 info!(
312 event_type = entry.event_type.as_str(),
313 secret_type = entry.secret_type.as_str(),
314 subject = ?entry.subject,
315 operation = entry.operation,
316 context = ?entry.context,
317 "Security event: successful operation"
318 );
319 } else {
320 warn!(
321 event_type = entry.event_type.as_str(),
322 secret_type = entry.secret_type.as_str(),
323 subject = ?entry.subject,
324 operation = entry.operation,
325 error = ?entry.error_message,
326 context = ?entry.context,
327 "Security event: failed operation"
328 );
329 }
330 }
331}
332
333/// Global audit logger instance.
334///
335/// Initialized once per process via [`init_audit_logger`]. Subsequent calls to
336/// `init_audit_logger` are silently ignored — the first caller wins.
337///
338/// **Test isolation**: In a test binary all tests share this singleton. The first
339/// test that calls `init_audit_logger` sets the logger for the entire process; later
340/// tests see that logger regardless of what they pass. Do not assert on specific
341/// logger state across test functions in the same binary. Use
342/// [`get_audit_logger`] to read the current value.
343pub static AUDIT_LOGGER: std::sync::OnceLock<Arc<dyn AuditLogger>> = std::sync::OnceLock::new();
344
345/// Initialize the global audit logger
346pub fn init_audit_logger(logger: Arc<dyn AuditLogger>) {
347 let _ = AUDIT_LOGGER.set(logger);
348}
349
350/// Get the global audit logger (defaults to structured logging if not initialized)
351pub fn get_audit_logger() -> Arc<dyn AuditLogger> {
352 AUDIT_LOGGER.get_or_init(|| Arc::new(StructuredAuditLogger::new())).clone()
353}
354
355/// Extension trait that adds audit logging to any `Result<T, E>`.
356///
357/// Calling `.audit_log(...)` on a result logs the success or failure via the
358/// global [`AuditLogger`] and then returns the result unchanged. This allows
359/// audit logging to be inserted into call chains without altering the return type:
360///
361/// ```ignore
362/// let claims = validator
363/// .validate(token, &public_key)
364/// .audit_log(AuditEventType::JwtValidation, SecretType::JwtToken, None, "validate")?;
365/// ```
366pub trait AuditExt<T, E> {
367 /// Log success or failure of a result
368 ///
369 /// # Errors
370 ///
371 /// Returns the original error `E` unchanged after logging the failure.
372 fn audit_log(
373 self,
374 event_type: AuditEventType,
375 secret_type: SecretType,
376 subject: Option<String>,
377 operation: &str,
378 ) -> Result<T, E>;
379}
380
381impl<T, E: std::fmt::Display> AuditExt<T, E> for Result<T, E> {
382 fn audit_log(
383 self,
384 event_type: AuditEventType,
385 secret_type: SecretType,
386 subject: Option<String>,
387 operation: &str,
388 ) -> Result<T, E> {
389 let logger = get_audit_logger();
390 match &self {
391 Ok(_) => logger.log_success(event_type, secret_type, subject, operation),
392 Err(e) => {
393 logger.log_failure(event_type, secret_type, subject, operation, &e.to_string());
394 },
395 }
396 self
397 }
398}
399
400#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
401#[cfg(test)]
402mod tests {
403 use std::sync::Mutex;
404
405 #[allow(clippy::wildcard_imports)]
406 // Reason: test module — wildcard keeps test boilerplate minimal
407 use super::*;
408
409 struct TestAuditLogger {
410 entries: Mutex<Vec<AuditEntry>>,
411 }
412
413 impl TestAuditLogger {
414 fn new() -> Self {
415 Self {
416 entries: Mutex::new(Vec::new()),
417 }
418 }
419
420 fn get_entries(&self) -> Vec<AuditEntry> {
421 self.entries.lock().unwrap().clone()
422 }
423 }
424
425 impl AuditLogger for TestAuditLogger {
426 fn log_entry(&self, entry: AuditEntry) {
427 self.entries.lock().unwrap().push(entry);
428 }
429 }
430
431 #[test]
432 fn test_audit_entry_creation() {
433 let entry = AuditEntry {
434 event_type: AuditEventType::JwtValidation,
435 secret_type: SecretType::JwtToken,
436 subject: Some("user123".to_string()),
437 operation: "validate".to_string(),
438 success: true,
439 error_message: None,
440 context: None,
441 chain_hash: None,
442 };
443
444 assert_eq!(entry.event_type, AuditEventType::JwtValidation);
445 assert_eq!(entry.subject, Some("user123".to_string()));
446 assert!(entry.success);
447 }
448
449 #[test]
450 fn test_audit_logger_logs_entry() {
451 let logger = TestAuditLogger::new();
452
453 logger.log_success(
454 AuditEventType::JwtValidation,
455 SecretType::JwtToken,
456 Some("user123".to_string()),
457 "validate",
458 );
459
460 let entries = logger.get_entries();
461 assert_eq!(entries.len(), 1);
462 assert!(entries[0].success);
463 }
464
465 #[test]
466 fn test_audit_logger_logs_failure() {
467 let logger = TestAuditLogger::new();
468
469 logger.log_failure(
470 AuditEventType::JwtValidation,
471 SecretType::JwtToken,
472 Some("user123".to_string()),
473 "validate",
474 "Invalid signature",
475 );
476
477 let entries = logger.get_entries();
478 assert_eq!(entries.len(), 1);
479 assert!(!entries[0].success);
480 assert_eq!(entries[0].error_message, Some("Invalid signature".to_string()));
481 }
482
483 #[test]
484 fn test_event_type_strings() {
485 assert_eq!(AuditEventType::JwtValidation.as_str(), "jwt_validation");
486 assert_eq!(AuditEventType::OidcTokenExchange.as_str(), "oidc_token_exchange");
487 }
488
489 #[test]
490 fn test_secret_type_strings() {
491 assert_eq!(SecretType::JwtToken.as_str(), "jwt_token");
492 assert_eq!(SecretType::ClientSecret.as_str(), "client_secret");
493 }
494
495 // Vulnerability #15: Audit logger bounds documentation tests
496 #[test]
497 fn test_bounds_constants_are_reasonable() {
498 use crate::audit::logger::bounds;
499
500 // Subject length should accommodate typical user IDs
501 let max_subject = bounds::MAX_SUBJECT_LEN;
502 assert!(max_subject >= 128, "Subject length too small");
503
504 // Operation should cover all operation types
505 let max_operation = bounds::MAX_OPERATION_LEN;
506 assert!(max_operation >= 20, "Operation length too small");
507
508 // Error messages should have room for context
509 let max_error = bounds::MAX_ERROR_MESSAGE_LEN;
510 assert!(max_error >= 512, "Error message length too small");
511
512 // Context should allow some additional data
513 let max_context = bounds::MAX_CONTEXT_LEN;
514 assert!(max_context >= 1024, "Context length too small");
515
516 // In-memory buffer should be large but not excessive
517 let max_entries = bounds::MAX_ENTRIES_IN_MEMORY;
518 assert!(max_entries >= 1000, "Max entries in memory too small");
519 assert!(max_entries <= 100_000, "Max entries in memory too large");
520 }
521
522 #[test]
523 fn test_bounds_constants_match_documentation() {
524 use crate::audit::logger::bounds;
525
526 // Verify documented bounds match constants
527 assert_eq!(bounds::MAX_SUBJECT_LEN, 256, "Subject length bound mismatch");
528 assert_eq!(bounds::MAX_OPERATION_LEN, 50, "Operation length bound mismatch");
529 assert_eq!(bounds::MAX_ERROR_MESSAGE_LEN, 1024, "Error message length bound mismatch");
530 assert_eq!(bounds::MAX_CONTEXT_LEN, 2048, "Context length bound mismatch");
531 assert_eq!(bounds::MAX_ENTRIES_IN_MEMORY, 10_000, "Max entries in memory bound mismatch");
532 }
533
534 #[test]
535 fn test_memory_per_entry_constant_is_reasonable() {
536 use crate::audit::logger::bounds;
537
538 // Memory per entry should be sensible
539 let bytes_per_entry = bounds::BYTES_PER_ENTRY;
540 let max_entries = bounds::MAX_ENTRIES_IN_MEMORY;
541 let total_memory_mb = (bytes_per_entry * max_entries) / (1024 * 1024);
542
543 // Total memory for full buffer should be reasonable (< 100 MB)
544 assert!(
545 total_memory_mb < 100,
546 "Total memory for full buffer too large: {} MB",
547 total_memory_mb
548 );
549
550 // But not absurdly small (> 10 MB for safety margin)
551 assert!(
552 total_memory_mb > 10,
553 "Total memory for full buffer too small: {} MB",
554 total_memory_mb
555 );
556 }
557
558 #[test]
559 fn test_audit_entry_field_sizes_within_bounds() {
560 use crate::audit::logger::bounds;
561
562 let entry = AuditEntry {
563 event_type: AuditEventType::JwtValidation,
564 secret_type: SecretType::JwtToken,
565 subject: Some("a".repeat(bounds::MAX_SUBJECT_LEN)),
566 operation: "validate".to_string(),
567 success: true,
568 error_message: None,
569 context: None,
570 chain_hash: None,
571 };
572
573 // Verify subject fits within bounds
574 assert!(entry.subject.as_ref().unwrap().len() <= bounds::MAX_SUBJECT_LEN);
575 }
576
577 #[test]
578 fn test_error_message_bound_accommodates_typical_errors() {
579 use crate::audit::logger::bounds;
580
581 // Typical security errors should fit
582 let error_messages = vec![
583 "Invalid signature",
584 "Token expired",
585 "User not authorized",
586 "Failed to decrypt payload: AES-256-GCM decryption returned InvalidTag",
587 "Database connection timeout after 30 seconds waiting for available connection",
588 ];
589
590 for msg in error_messages {
591 assert!(
592 msg.len() <= bounds::MAX_ERROR_MESSAGE_LEN,
593 "Error message too long: {} bytes for: {}",
594 msg.len(),
595 msg
596 );
597 }
598 }
599
600 #[test]
601 fn test_operation_bound_covers_all_audit_operations() {
602 use crate::audit::logger::bounds;
603
604 // All documented operation names should fit
605 let operations = vec![
606 "validate", "create", "revoke", "refresh", "exchange", "logout",
607 ];
608
609 for op in operations {
610 assert!(
611 op.len() <= bounds::MAX_OPERATION_LEN,
612 "Operation name too long: {} bytes for: {}",
613 op.len(),
614 op
615 );
616 }
617 }
618
619 #[test]
620 fn test_global_audit_logger_is_singleton() {
621 // Verify that the global audit logger can only be initialized once
622 let logger1 = get_audit_logger();
623 let logger2 = get_audit_logger();
624
625 // Both should return the same instance
626 assert_eq!(
627 Arc::as_ptr(&logger1),
628 Arc::as_ptr(&logger2),
629 "Audit loggers are not the same singleton instance"
630 );
631 }
632
633 #[test]
634 fn test_audit_entry_sizes_reasonable_for_serialization() {
635 use crate::audit::logger::bounds;
636
637 // Create a maximum-size entry
638 let max_entry = AuditEntry {
639 event_type: AuditEventType::JwtValidation,
640 secret_type: SecretType::JwtToken,
641 subject: Some("a".repeat(bounds::MAX_SUBJECT_LEN)),
642 operation: "validate".to_string(),
643 success: false,
644 error_message: Some("e".repeat(bounds::MAX_ERROR_MESSAGE_LEN)),
645 context: Some("c".repeat(bounds::MAX_CONTEXT_LEN)),
646 chain_hash: None,
647 };
648
649 // Serialize to JSON to estimate size
650 let json = serde_json::to_string(&max_entry);
651 assert!(json.is_ok(), "Failed to serialize maximum-size entry");
652
653 let json_size = json.unwrap().len();
654 // JSON should be reasonable size (with overhead, but < 2x fields)
655 assert!(
656 json_size < bounds::BYTES_PER_ENTRY * 2,
657 "JSON serialization too large: {} bytes",
658 json_size
659 );
660 }
661}