Skip to main content

allowthem_core/
event_sink.rs

1use std::future::Future;
2use std::pin::Pin;
3
4use chrono::{DateTime, Utc};
5use serde::Serialize;
6use serde_json::Value;
7use uuid::Uuid;
8
9use crate::types::UserId;
10
11/// A single auth event emitted by an `AllowThem` operation.
12///
13/// Events are stringly-typed (`event_type`) with a JSON `data` bag so that
14/// new event types can be added in future tasks without a breaking API change.
15/// Webhook delivery (epic 7xw.2) will serialise this struct to JSON.
16///
17/// `event_id` is a per-event UUIDv7 generated at construction time. The same
18/// id is shared across every `webhook_deliveries` row produced from this event
19/// so receivers can dedupe across retries and across multiple subscriptions.
20///
21/// Data shapes are per-`event_type` and may evolve between minor versions.
22#[derive(Debug, Clone, Serialize)]
23pub struct AuthEvent {
24    pub event_id: Uuid,
25    pub event_type: String,
26    pub user_id: Option<UserId>,
27    pub timestamp: DateTime<Utc>,
28    pub data: Value,
29}
30
31impl AuthEvent {
32    /// Construct an `AuthEvent`, stamping `event_id` with `Uuid::now_v7()` and
33    /// `timestamp` with `Utc::now()`.
34    pub fn new(event_type: impl Into<String>, user_id: Option<UserId>, data: Value) -> Self {
35        Self {
36            event_id: Uuid::now_v7(),
37            event_type: event_type.into(),
38            user_id,
39            timestamp: Utc::now(),
40            data,
41        }
42    }
43}
44
45/// Abstraction over event delivery.
46///
47/// Implementors receive every state-changing auth operation as an `AuthEvent`.
48/// The library provides [`NoopEventSink`] (silent default) and
49/// [`LoggingEventSink`] (dev logging). The SaaS binary will register a sink
50/// that writes rows to `webhook_deliveries` for outbound HTTP delivery
51/// (epic 7xw.2).
52///
53/// ## Contract
54///
55/// - Returns `()` — no error type to ignore. Swallow internal errors via
56///   `tracing::warn!` rather than propagating.
57/// - **Must not panic.** A panic propagates to the caller and aborts the
58///   in-progress request.
59/// - **Must be fast.** No outbound HTTP. The SaaS sink writes one row;
60///   delivery happens out-of-band.
61pub trait EventSink: Send + Sync {
62    fn emit<'a>(&'a self, event: &'a AuthEvent) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
63}
64
65/// Silent default event sink.
66///
67/// Ignores all events. The expected default for embedded integrators that
68/// do not need webhook delivery.
69pub struct NoopEventSink;
70
71impl EventSink for NoopEventSink {
72    fn emit<'a>(&'a self, _event: &'a AuthEvent) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
73        Box::pin(std::future::ready(()))
74    }
75}
76
77/// Development event sink that logs events at `debug` level.
78///
79/// Writes `event_type`, `user_id`, and `data` to the tracing log. Does not
80/// perform any I/O. Suitable for the SaaS binary's dev startup.
81pub struct LoggingEventSink;
82
83impl EventSink for LoggingEventSink {
84    fn emit<'a>(&'a self, event: &'a AuthEvent) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
85        tracing::debug!(
86            event_type = %event.event_type,
87            user_id = ?event.user_id,
88            data = %event.data,
89            "auth event",
90        );
91        Box::pin(std::future::ready(()))
92    }
93}
94
95/// Allow any `Arc<T>` where `T: EventSink` to be used as an `EventSink`.
96impl<T: EventSink + ?Sized> EventSink for std::sync::Arc<T> {
97    fn emit<'a>(&'a self, event: &'a AuthEvent) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
98        (**self).emit(event)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    // Compile-time proof that EventSink is dyn-compatible.
107    fn _assert_object_safe(_: &dyn EventSink) {}
108
109    fn sample_event() -> AuthEvent {
110        AuthEvent::new("user.created", None, serde_json::json!({}))
111    }
112
113    #[tokio::test]
114    async fn noop_sink_returns_immediately() {
115        NoopEventSink.emit(&sample_event()).await;
116    }
117
118    #[tokio::test]
119    async fn logging_sink_returns_immediately() {
120        LoggingEventSink.emit(&sample_event()).await;
121    }
122
123    #[tokio::test]
124    async fn arc_dispatch_works() {
125        let sink: std::sync::Arc<dyn EventSink> = std::sync::Arc::new(NoopEventSink);
126        sink.emit(&sample_event()).await;
127    }
128
129    #[tokio::test]
130    async fn auth_event_new_stamps_timestamp() {
131        let before = Utc::now();
132        let event = AuthEvent::new("test", None, serde_json::json!({"k": "v"}));
133        let after = Utc::now();
134        assert!(event.timestamp >= before);
135        assert!(event.timestamp <= after);
136        assert_eq!(event.event_type, "test");
137        assert!(event.user_id.is_none());
138    }
139
140    #[tokio::test]
141    async fn auth_event_new_assigns_distinct_non_nil_event_ids() {
142        let a = AuthEvent::new("test", None, serde_json::json!({}));
143        let b = AuthEvent::new("test", None, serde_json::json!({}));
144        assert_ne!(a.event_id, Uuid::nil());
145        assert_ne!(b.event_id, Uuid::nil());
146        assert_ne!(a.event_id, b.event_id);
147    }
148
149    #[tokio::test]
150    async fn auth_event_serializes_event_id_field() {
151        let event = AuthEvent::new("test", None, serde_json::json!({}));
152        let json = serde_json::to_value(&event).unwrap();
153        assert_eq!(
154            json["event_id"].as_str().unwrap(),
155            event.event_id.to_string()
156        );
157    }
158}