Skip to main content

pylon_auth/
audit.rs

1//! Append-only audit log for security-relevant events.
2//!
3//! Records the **who, when, what, from where** of every auth state
4//! change so SIEM tooling + customer compliance asks can reconstruct
5//! the timeline. Designed for ops to trust:
6//!
7//! - **Append-only by API** — no `update`/`delete` on the trait. SQL
8//!   backends can still be tampered with at the DB layer; that's a
9//!   separate problem (DB user permissions, immudb, etc.).
10//! - **Tenant-scoped queries** — `find_for_user` / `find_for_tenant`
11//!   enforce isolation at the store layer so the wrong query
12//!   parameters can't accidentally leak cross-tenant events.
13//! - **Bounded payload** — events carry a fixed-shape struct, not
14//!   arbitrary JSON. Apps that want richer payloads stash structured
15//!   metadata in `metadata: Map<String, String>` (string-only values
16//!   to keep PII surface predictable).
17//!
18//! Wire format intentionally short on detail (no full request bodies,
19//! no Authorization headers, no passwords). Operators should pair
20//! the log with proper request-tracing for debugging.
21
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::sync::Mutex;
25
26/// One audit-log row. Writes are append-only; the only mutation
27/// path is creating a new event.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct AuditEvent {
30    /// Stable id (`evt_<24-base64url>`).
31    pub id: String,
32    /// Unix-epoch seconds. Wall-clock from the server's perspective.
33    pub created_at: u64,
34    /// What happened. Stable enum so SIEM dashboards can match on
35    /// well-known names. Apps that need bespoke events use
36    /// `AuditAction::Custom("...")`.
37    pub action: AuditAction,
38    /// User the event is ABOUT (subject). Distinct from `actor_id`
39    /// — an admin disabling a user's account has actor=admin,
40    /// subject=user.
41    pub user_id: Option<String>,
42    /// User who PERFORMED the action. Same as `user_id` for self-
43    /// service flows. None for system-driven events
44    /// (token-refresh tick, scheduled cleanup).
45    pub actor_id: Option<String>,
46    /// Active org / tenant when the action happened — set when the
47    /// caller's session had `tenant_id`.
48    pub tenant_id: Option<String>,
49    /// Source IP of the request. Apps with a CDN should ensure this
50    /// is the REAL client IP (X-Forwarded-For has been parsed).
51    pub ip: Option<String>,
52    /// Truncated User-Agent string. Cap at 256 chars at write time.
53    pub user_agent: Option<String>,
54    /// True iff `action` succeeded. Failed-login events are still
55    /// logged with `success=false` so SIEM can spot brute force.
56    pub success: bool,
57    /// Free-form short reason on failure ("WRONG_PASSWORD",
58    /// "RATE_LIMITED"). Plain strings — no template interpolation.
59    pub reason: Option<String>,
60    /// Stringly-typed structured metadata. Avoid putting secrets
61    /// here; the audit log is meant to be readable by ops.
62    pub metadata: HashMap<String, String>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum AuditAction {
68    /// Successful sign-in via any path. `metadata.method` carries
69    /// the specific path ("password", "magic_code", "magic_link",
70    /// "oauth:google", "passkey", "siwe", "phone").
71    SignIn,
72    SignOut,
73    /// Failed sign-in (wrong password, expired magic, bad TOTP).
74    SignInFailed,
75    /// User row created via any sign-up path.
76    SignUp,
77    /// Self-service password change (with current password).
78    PasswordChange,
79    /// Password reset via emailed token.
80    PasswordReset,
81    EmailChange,
82    /// TOTP enrollment finalized (first verify after enroll).
83    TotpEnroll,
84    /// TOTP disabled by the user.
85    TotpDisable,
86    /// Backup codes regenerated (invalidates the prior set).
87    TotpBackupCodesRegenerate,
88    PasskeyRegister,
89    PasskeyRevoke,
90    ApiKeyCreate,
91    ApiKeyRevoke,
92    OauthLink,
93    OauthUnlink,
94    OrgCreate,
95    OrgDelete,
96    OrgInviteSend,
97    OrgInviteAccept,
98    OrgMemberRemove,
99    OrgRoleChange,
100    AccountDelete,
101    /// Apps that need a custom event use this with their own string.
102    /// Stored verbatim — pylon doesn't validate the content.
103    Custom(String),
104}
105
106impl AuditAction {
107    pub fn as_str(&self) -> &str {
108        match self {
109            Self::SignIn => "sign_in",
110            Self::SignOut => "sign_out",
111            Self::SignInFailed => "sign_in_failed",
112            Self::SignUp => "sign_up",
113            Self::PasswordChange => "password_change",
114            Self::PasswordReset => "password_reset",
115            Self::EmailChange => "email_change",
116            Self::TotpEnroll => "totp_enroll",
117            Self::TotpDisable => "totp_disable",
118            Self::TotpBackupCodesRegenerate => "totp_backup_codes_regenerate",
119            Self::PasskeyRegister => "passkey_register",
120            Self::PasskeyRevoke => "passkey_revoke",
121            Self::ApiKeyCreate => "api_key_create",
122            Self::ApiKeyRevoke => "api_key_revoke",
123            Self::OauthLink => "oauth_link",
124            Self::OauthUnlink => "oauth_unlink",
125            Self::OrgCreate => "org_create",
126            Self::OrgDelete => "org_delete",
127            Self::OrgInviteSend => "org_invite_send",
128            Self::OrgInviteAccept => "org_invite_accept",
129            Self::OrgMemberRemove => "org_member_remove",
130            Self::OrgRoleChange => "org_role_change",
131            Self::AccountDelete => "account_delete",
132            Self::Custom(s) => s,
133        }
134    }
135}
136
137/// Minimal builder for the per-request fields that the route
138/// handler can grab from the RouterContext.
139#[derive(Debug, Clone, Default)]
140pub struct AuditEventBuilder {
141    pub action: Option<AuditAction>,
142    pub user_id: Option<String>,
143    pub actor_id: Option<String>,
144    pub tenant_id: Option<String>,
145    pub ip: Option<String>,
146    pub user_agent: Option<String>,
147    pub success: bool,
148    pub reason: Option<String>,
149    pub metadata: HashMap<String, String>,
150}
151
152impl AuditEventBuilder {
153    pub fn new(action: AuditAction) -> Self {
154        Self {
155            action: Some(action),
156            success: true,
157            ..Default::default()
158        }
159    }
160    pub fn user(mut self, user_id: impl Into<String>) -> Self {
161        self.user_id = Some(user_id.into());
162        self
163    }
164    pub fn actor(mut self, actor_id: impl Into<String>) -> Self {
165        self.actor_id = Some(actor_id.into());
166        self
167    }
168    pub fn tenant(mut self, tenant_id: impl Into<String>) -> Self {
169        self.tenant_id = Some(tenant_id.into());
170        self
171    }
172    pub fn ip(mut self, ip: impl Into<String>) -> Self {
173        self.ip = Some(ip.into());
174        self
175    }
176    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
177        let s = ua.into();
178        // Cap UA at 256 chars — long UAs are usually a fingerprint
179        // attempt, not real data. Truncating here saves the SQL
180        // backend from oversized rows.
181        let truncated: String = s.chars().take(256).collect();
182        self.user_agent = Some(truncated);
183        self
184    }
185    pub fn failed(mut self, reason: impl Into<String>) -> Self {
186        self.success = false;
187        self.reason = Some(reason.into());
188        self
189    }
190    pub fn meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
191        self.metadata.insert(key.into(), value.into());
192        self
193    }
194    pub fn build(self) -> AuditEvent {
195        let action = self.action.unwrap_or(AuditAction::Custom("unknown".into()));
196        AuditEvent {
197            id: format!("evt_{}", random_token(20)),
198            created_at: now_secs(),
199            action,
200            user_id: self.user_id,
201            actor_id: self.actor_id,
202            tenant_id: self.tenant_id,
203            ip: self.ip,
204            user_agent: self.user_agent,
205            success: self.success,
206            reason: self.reason,
207            metadata: self.metadata,
208        }
209    }
210}
211
212pub trait AuditBackend: Send + Sync {
213    fn append(&self, event: &AuditEvent);
214    /// Tenant-scoped query. Returns at most `limit` events newest-first.
215    /// Backends MUST respect `tenant_id` to prevent cross-tenant leak.
216    fn find_for_tenant(&self, tenant_id: &str, limit: usize) -> Vec<AuditEvent>;
217    /// User-scoped query. Returns events where the user is the
218    /// subject OR the actor.
219    fn find_for_user(&self, user_id: &str, limit: usize) -> Vec<AuditEvent>;
220}
221
222pub struct InMemoryAuditBackend {
223    events: Mutex<Vec<AuditEvent>>,
224}
225
226impl Default for InMemoryAuditBackend {
227    fn default() -> Self {
228        Self {
229            events: Mutex::new(Vec::new()),
230        }
231    }
232}
233
234impl AuditBackend for InMemoryAuditBackend {
235    fn append(&self, event: &AuditEvent) {
236        self.events.lock().unwrap().push(event.clone());
237    }
238    fn find_for_tenant(&self, tenant_id: &str, limit: usize) -> Vec<AuditEvent> {
239        let g = self.events.lock().unwrap();
240        let mut out: Vec<AuditEvent> = g
241            .iter()
242            .filter(|e| e.tenant_id.as_deref() == Some(tenant_id))
243            .cloned()
244            .collect();
245        out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
246        out.truncate(limit);
247        out
248    }
249    fn find_for_user(&self, user_id: &str, limit: usize) -> Vec<AuditEvent> {
250        let g = self.events.lock().unwrap();
251        let mut out: Vec<AuditEvent> = g
252            .iter()
253            .filter(|e| {
254                e.user_id.as_deref() == Some(user_id) || e.actor_id.as_deref() == Some(user_id)
255            })
256            .cloned()
257            .collect();
258        out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
259        out.truncate(limit);
260        out
261    }
262}
263
264pub struct AuditStore {
265    backend: Box<dyn AuditBackend>,
266}
267
268impl Default for AuditStore {
269    fn default() -> Self {
270        Self::new()
271    }
272}
273
274impl AuditStore {
275    pub fn new() -> Self {
276        Self::with_backend(Box::new(InMemoryAuditBackend::default()))
277    }
278    pub fn with_backend(backend: Box<dyn AuditBackend>) -> Self {
279        Self { backend }
280    }
281
282    /// Convenience: build + append in one call. Most call sites do
283    /// `store.log(AuditEventBuilder::new(...).user(...).build())`.
284    pub fn log(&self, event: AuditEvent) {
285        self.backend.append(&event);
286    }
287
288    pub fn find_for_tenant(&self, tenant_id: &str, limit: usize) -> Vec<AuditEvent> {
289        self.backend.find_for_tenant(tenant_id, limit)
290    }
291    pub fn find_for_user(&self, user_id: &str, limit: usize) -> Vec<AuditEvent> {
292        self.backend.find_for_user(user_id, limit)
293    }
294}
295
296fn random_token(n_bytes: usize) -> String {
297    use rand::RngCore;
298    let mut bytes = vec![0u8; n_bytes];
299    rand::thread_rng().fill_bytes(&mut bytes);
300    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
301    URL_SAFE_NO_PAD.encode(bytes)
302}
303
304fn now_secs() -> u64 {
305    use std::time::{SystemTime, UNIX_EPOCH};
306    SystemTime::now()
307        .duration_since(UNIX_EPOCH)
308        .unwrap_or_default()
309        .as_secs()
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn builder_default_success_true() {
318        let e = AuditEventBuilder::new(AuditAction::SignIn).build();
319        assert!(e.success);
320        assert!(e.reason.is_none());
321    }
322
323    #[test]
324    fn builder_failed_flips_success_and_records_reason() {
325        let e = AuditEventBuilder::new(AuditAction::SignInFailed)
326            .failed("WRONG_PASSWORD")
327            .build();
328        assert!(!e.success);
329        assert_eq!(e.reason.as_deref(), Some("WRONG_PASSWORD"));
330    }
331
332    #[test]
333    fn user_agent_truncated_to_256_chars() {
334        let huge_ua = "X".repeat(2000);
335        let e = AuditEventBuilder::new(AuditAction::SignIn)
336            .user_agent(huge_ua)
337            .build();
338        assert_eq!(e.user_agent.as_ref().unwrap().chars().count(), 256);
339    }
340
341    #[test]
342    fn tenant_query_isolates_cross_tenant() {
343        // Critical isolation check: events tagged with tenant=A
344        // must NEVER leak into a tenant=B query.
345        let s = AuditStore::new();
346        s.log(
347            AuditEventBuilder::new(AuditAction::SignIn)
348                .tenant("tenant_a")
349                .user("u1")
350                .build(),
351        );
352        s.log(
353            AuditEventBuilder::new(AuditAction::SignIn)
354                .tenant("tenant_b")
355                .user("u2")
356                .build(),
357        );
358        s.log(
359            AuditEventBuilder::new(AuditAction::SignIn)
360                // No tenant — should not leak into either query.
361                .user("u3")
362                .build(),
363        );
364        let a = s.find_for_tenant("tenant_a", 100);
365        assert_eq!(a.len(), 1);
366        assert_eq!(a[0].user_id.as_deref(), Some("u1"));
367        let b = s.find_for_tenant("tenant_b", 100);
368        assert_eq!(b.len(), 1);
369        assert_eq!(b[0].user_id.as_deref(), Some("u2"));
370    }
371
372    #[test]
373    fn user_query_returns_subject_and_actor_events() {
374        // An admin disabling a user's account: actor=admin, user=alice.
375        // Both queries should surface it (alice sees what happened to
376        // her; admin sees what they did).
377        let s = AuditStore::new();
378        s.log(
379            AuditEventBuilder::new(AuditAction::AccountDelete)
380                .user("alice")
381                .actor("admin")
382                .build(),
383        );
384        assert_eq!(s.find_for_user("alice", 100).len(), 1);
385        assert_eq!(s.find_for_user("admin", 100).len(), 1);
386        assert_eq!(s.find_for_user("bob", 100).len(), 0);
387    }
388
389    #[test]
390    fn newest_first_ordering() {
391        let s = AuditStore::new();
392        // Inject events with explicit timestamps to defeat clock noise.
393        s.backend.append(&AuditEvent {
394            id: "evt_a".into(),
395            created_at: 100,
396            action: AuditAction::SignIn,
397            user_id: Some("u".into()),
398            actor_id: None,
399            tenant_id: Some("t".into()),
400            ip: None,
401            user_agent: None,
402            success: true,
403            reason: None,
404            metadata: HashMap::new(),
405        });
406        s.backend.append(&AuditEvent {
407            id: "evt_b".into(),
408            created_at: 200,
409            action: AuditAction::SignOut,
410            user_id: Some("u".into()),
411            actor_id: None,
412            tenant_id: Some("t".into()),
413            ip: None,
414            user_agent: None,
415            success: true,
416            reason: None,
417            metadata: HashMap::new(),
418        });
419        let out = s.find_for_tenant("t", 10);
420        assert_eq!(out[0].id, "evt_b"); // newest first
421        assert_eq!(out[1].id, "evt_a");
422    }
423
424    #[test]
425    fn limit_caps_results() {
426        let s = AuditStore::new();
427        for i in 0..50 {
428            s.log(
429                AuditEventBuilder::new(AuditAction::SignIn)
430                    .tenant("t")
431                    .user(format!("u_{i}"))
432                    .build(),
433            );
434        }
435        assert_eq!(s.find_for_tenant("t", 10).len(), 10);
436    }
437
438    #[test]
439    fn metadata_preserves_string_only_values() {
440        // Defends against a future caller passing JSON values that
441        // could contain nested PII or tokens. Stringly-typed by design.
442        let e = AuditEventBuilder::new(AuditAction::SignIn)
443            .meta("method", "oauth:google")
444            .meta("device", "iPhone")
445            .build();
446        assert_eq!(
447            e.metadata.get("method").map(|s| s.as_str()),
448            Some("oauth:google")
449        );
450        assert_eq!(e.metadata.len(), 2);
451    }
452
453    #[test]
454    fn custom_action_serializes_verbatim() {
455        let e = AuditEventBuilder::new(AuditAction::Custom(
456            "pylon.cloud.fly_machine_provision".into(),
457        ))
458        .build();
459        assert_eq!(e.action.as_str(), "pylon.cloud.fly_machine_provision");
460    }
461
462    #[test]
463    fn no_tenant_event_invisible_to_tenant_query() {
464        // System events without tenant context must never accidentally
465        // surface in a tenant-scoped query.
466        let s = AuditStore::new();
467        s.log(AuditEventBuilder::new(AuditAction::Custom("system.tick".into())).build());
468        assert_eq!(s.find_for_tenant("tenant_a", 100).len(), 0);
469    }
470}