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}