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    /// An authorization check denied access (RLS block, RBAC field denial, scope mismatch).
104    ///
105    /// Authentication succeeded, but the authenticated principal lacks the
106    /// required permissions for the requested resource.  This event is emitted
107    /// for compliance (SOC 2) audit trails and is distinct from `AuthFailure`,
108    /// which covers failed authentication.
109    AuthorizationDenied,
110}
111
112impl AuditEventType {
113    /// Return a stable, lowercase snake_case string representation of this event type.
114    ///
115    /// These strings are written to log sinks and compliance systems and will not
116    /// change between releases.
117    #[must_use]
118    pub const fn as_str(&self) -> &'static str {
119        match self {
120            AuditEventType::JwtValidation => "jwt_validation",
121            AuditEventType::JwtRefresh => "jwt_refresh",
122            AuditEventType::OidcCredentialAccess => "oidc_credential_access",
123            AuditEventType::OidcTokenExchange => "oidc_token_exchange",
124            AuditEventType::SessionTokenCreated => "session_token_created",
125            AuditEventType::SessionTokenValidation => "session_token_validation",
126            AuditEventType::SessionTokenRevoked => "session_token_revoked",
127            AuditEventType::CsrfStateGenerated => "csrf_state_generated",
128            AuditEventType::CsrfStateValidated => "csrf_state_validated",
129            AuditEventType::OauthStart => "oauth_start",
130            AuditEventType::OauthCallback => "oauth_callback",
131            AuditEventType::AuthSuccess => "auth_success",
132            AuditEventType::AuthFailure => "auth_failure",
133            AuditEventType::AuthorizationDenied => "authorization_denied",
134        }
135    }
136}
137
138/// Classifies the secret credential that was accessed or operated on.
139///
140/// Used in [`AuditEntry`] to identify the category of credential involved in a
141/// security event.  This enables compliance queries such as "show all events
142/// involving client secrets" or "which refresh tokens were revoked today".
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[non_exhaustive]
145pub enum SecretType {
146    /// A JWT access token (short-lived bearer credential).
147    JwtToken,
148    /// A session token issued by FraiseQL's session store.
149    SessionToken,
150    /// An OAuth 2.0 client secret used for provider authentication.
151    ClientSecret,
152    /// A long-lived refresh token used to obtain new access tokens.
153    RefreshToken,
154    /// A one-time authorization code returned by the OAuth provider.
155    AuthorizationCode,
156    /// An OAuth `state` token used to carry CSRF and flow metadata.
157    StateToken,
158    /// A CSRF token used to bind a user-agent session to an OAuth flow.
159    CsrfToken,
160}
161
162impl SecretType {
163    /// Return a stable, lowercase snake_case string representation of this secret type.
164    ///
165    /// Written to log sinks and compliance systems; will not change between releases.
166    #[must_use]
167    pub const fn as_str(&self) -> &'static str {
168        match self {
169            SecretType::JwtToken => "jwt_token",
170            SecretType::SessionToken => "session_token",
171            SecretType::ClientSecret => "client_secret",
172            SecretType::RefreshToken => "refresh_token",
173            SecretType::AuthorizationCode => "authorization_code",
174            SecretType::StateToken => "state_token",
175            SecretType::CsrfToken => "csrf_token",
176        }
177    }
178}
179
180/// Audit log entry
181///
182/// # Size Bounds
183///
184/// To prevent memory exhaustion and ensure predictable performance, each field
185/// is bounded in size:
186///
187/// - `subject`: Max 256 bytes (see `bounds::MAX_SUBJECT_LEN`)
188/// - `operation`: Max 50 bytes (see `bounds::MAX_OPERATION_LEN`)
189/// - `error_message`: Max 1 KB (see `bounds::MAX_ERROR_MESSAGE_LEN`)
190/// - `context`: Max 2 KB (see `bounds::MAX_CONTEXT_LEN`)
191/// - **Total per entry**: ~4 KB
192///
193/// # Thread Safety
194///
195/// This struct is immutable once created and `Send + Sync`, making it safe to
196/// pass between threads. Audit loggers that implement `AuditLogger` trait are
197/// responsible for thread-safe storage.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct AuditEntry {
200    /// Event type (jwt_validation, oauth_callback, etc.)
201    pub event_type:    AuditEventType,
202    /// Type of secret accessed (jwt_token, session_token, etc.)
203    pub secret_type:   SecretType,
204    /// Subject (user ID, service account, etc.) - None for anonymous
205    /// Max 256 bytes per `bounds::MAX_SUBJECT_LEN`
206    pub subject:       Option<String>,
207    /// Operation performed (validate, create, revoke, etc.)
208    /// Max 50 bytes per `bounds::MAX_OPERATION_LEN`
209    pub operation:     String,
210    /// Whether the operation succeeded
211    pub success:       bool,
212    /// Error message if operation failed (user-safe message)
213    /// Max 1 KB per `bounds::MAX_ERROR_MESSAGE_LEN`
214    pub error_message: Option<String>,
215    /// Additional context
216    /// Max 2 KB per `bounds::MAX_CONTEXT_LEN`
217    pub context:       Option<String>,
218    /// HMAC-SHA256 chain hash for tamper detection (64 hex chars).
219    ///
220    /// Each entry's hash depends on all previous entries, making retroactive
221    /// tampering detectable. `None` when tamper-evident logging is disabled.
222    /// Verify with [`crate::audit::chain::verify_chain`].
223    pub chain_hash:    Option<String>,
224}
225
226/// Audit logger trait - allows different implementations (structured logs, database, syslog, etc.)
227///
228/// # Implementation Requirements
229///
230/// - **Thread Safety**: All implementations must be `Send + Sync` and safe for concurrent access
231///   from multiple threads.
232/// - **Bounds Enforcement**: Implementations MAY enforce the bounds defined in this module, or
233///   delegate to a database backend that handles large-scale logging.
234/// - **Availability**: Should not block request handling; consider async/buffered implementations
235///   for performance.
236/// - **Error Handling**: Should never panic; swallow errors and log them via tracing instead.
237///
238/// # Memory Considerations
239///
240/// - **In-memory implementations**: Should limit to `bounds::MAX_ENTRIES_IN_MEMORY` to prevent
241///   unbounded growth.
242/// - **Production deployments**: Should use database-backed implementations
243///   (`ComplianceAuditLogger`) for scalability and retention.
244pub trait AuditLogger: Send + Sync {
245    /// Log an audit entry
246    ///
247    /// Implementations should ensure:
248    /// - No panics (errors logged via tracing)
249    /// - Thread-safe access to backing storage
250    /// - Bounded memory usage (for in-memory implementations)
251    fn log_entry(&self, entry: AuditEntry);
252
253    /// Convenience method for successful operations
254    fn log_success(
255        &self,
256        event_type: AuditEventType,
257        secret_type: SecretType,
258        subject: Option<String>,
259        operation: &str,
260    ) {
261        self.log_entry(AuditEntry {
262            event_type,
263            secret_type,
264            subject,
265            operation: operation.to_string(),
266            success: true,
267            error_message: None,
268            context: None,
269            chain_hash: None,
270        });
271    }
272
273    /// Convenience method for failed operations
274    fn log_failure(
275        &self,
276        event_type: AuditEventType,
277        secret_type: SecretType,
278        subject: Option<String>,
279        operation: &str,
280        error: &str,
281    ) {
282        self.log_entry(AuditEntry {
283            event_type,
284            secret_type,
285            subject,
286            operation: operation.to_string(),
287            success: false,
288            error_message: Some(error.to_string()),
289            context: None,
290            chain_hash: None,
291        });
292    }
293}
294
295/// Audit logger that emits structured log records via [`tracing`].
296///
297/// Successful operations are logged at `INFO` level; failures at `WARN` level.
298/// All fields of the [`AuditEntry`] are included as tracing fields so that
299/// log aggregators (Loki, Elasticsearch, Splunk, etc.) can filter and query them.
300///
301/// This is the default logger used when [`init_audit_logger`] is never called.
302/// For production compliance requirements, consider a database-backed logger.
303pub struct StructuredAuditLogger;
304
305impl StructuredAuditLogger {
306    /// Create a new `StructuredAuditLogger`.
307    #[must_use]
308    pub const fn new() -> Self {
309        Self
310    }
311}
312
313impl Default for StructuredAuditLogger {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319impl AuditLogger for StructuredAuditLogger {
320    fn log_entry(&self, entry: AuditEntry) {
321        if entry.success {
322            info!(
323                event_type = entry.event_type.as_str(),
324                secret_type = entry.secret_type.as_str(),
325                subject = ?entry.subject,
326                operation = entry.operation,
327                context = ?entry.context,
328                "Security event: successful operation"
329            );
330        } else {
331            warn!(
332                event_type = entry.event_type.as_str(),
333                secret_type = entry.secret_type.as_str(),
334                subject = ?entry.subject,
335                operation = entry.operation,
336                error = ?entry.error_message,
337                context = ?entry.context,
338                "Security event: failed operation"
339            );
340        }
341    }
342}
343
344/// Global audit logger instance.
345///
346/// Initialized once per process via [`init_audit_logger`]. Subsequent calls to
347/// `init_audit_logger` are silently ignored — the first caller wins.
348///
349/// **Test isolation**: In a test binary all tests share this singleton. The first
350/// test that calls `init_audit_logger` sets the logger for the entire process; later
351/// tests see that logger regardless of what they pass. Do not assert on specific
352/// logger state across test functions in the same binary. Use
353/// [`get_audit_logger`] to read the current value.
354pub static AUDIT_LOGGER: std::sync::OnceLock<Arc<dyn AuditLogger>> = std::sync::OnceLock::new();
355
356/// Initialize the global audit logger
357pub fn init_audit_logger(logger: Arc<dyn AuditLogger>) {
358    let _ = AUDIT_LOGGER.set(logger);
359}
360
361/// Get the global audit logger (defaults to structured logging if not initialized)
362pub fn get_audit_logger() -> Arc<dyn AuditLogger> {
363    AUDIT_LOGGER.get_or_init(|| Arc::new(StructuredAuditLogger::new())).clone()
364}
365
366/// Extension trait that adds audit logging to any `Result<T, E>`.
367///
368/// Calling `.audit_log(...)` on a result logs the success or failure via the
369/// global [`AuditLogger`] and then returns the result unchanged.  This allows
370/// audit logging to be inserted into call chains without altering the return type:
371///
372/// ```ignore
373/// let claims = validator
374///     .validate(token, &public_key)
375///     .audit_log(AuditEventType::JwtValidation, SecretType::JwtToken, None, "validate")?;
376/// ```
377pub trait AuditExt<T, E> {
378    /// Log success or failure of a result
379    ///
380    /// # Errors
381    ///
382    /// Returns the original error `E` unchanged after logging the failure.
383    fn audit_log(
384        self,
385        event_type: AuditEventType,
386        secret_type: SecretType,
387        subject: Option<String>,
388        operation: &str,
389    ) -> Result<T, E>;
390}
391
392impl<T, E: std::fmt::Display> AuditExt<T, E> for Result<T, E> {
393    fn audit_log(
394        self,
395        event_type: AuditEventType,
396        secret_type: SecretType,
397        subject: Option<String>,
398        operation: &str,
399    ) -> Result<T, E> {
400        let logger = get_audit_logger();
401        match &self {
402            Ok(_) => logger.log_success(event_type, secret_type, subject, operation),
403            Err(e) => {
404                logger.log_failure(event_type, secret_type, subject, operation, &e.to_string());
405            },
406        }
407        self
408    }
409}