Skip to main content

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}