Skip to main content

allowthem_saas/
webhook_sink.rs

1//! Per-tenant `EventSink` wiring for the SaaS webhook system.
2//!
3//! Background: `TenantBuilderConfig` historically carried a single
4//! `event_sink: Option<Arc<dyn EventSink>>` shared across every tenant
5//! handle. That is fine for stateless sinks (`LoggingEventSink`,
6//! `NoopEventSink`) but breaks the moment a sink needs to know *which*
7//! tenant produced the event — which it must, for the
8//! `tenant_webhooks` lookup to scope correctly.
9//!
10//! The fix is a factory: the saas runtime calls
11//! [`EventSinkFactory::for_tenant`] once per tenant `AllowThem` build
12//! and the resulting sink is bound to that tenant for its lifetime.
13//!
14//! `event_sink_factory` is added alongside the existing `event_sink`
15//! field on `TenantBuilderConfig` rather than replacing it. The
16//! handle-builder prefers the factory when both are present, so the
17//! upgrade is opt-in for the SaaS binary while embedded integrators
18//! and existing tests continue using the simpler shared-sink path.
19
20use std::future::Future;
21use std::pin::Pin;
22use std::sync::Arc;
23
24use allowthem_core::{AuthEvent, EventSink, LoggingEventSink};
25use sqlx::SqlitePool;
26use uuid::Uuid;
27
28use crate::tenants::TenantId;
29
30/// Constructs a per-tenant [`EventSink`].
31///
32/// Implementations must be cheap to call repeatedly — the runtime
33/// invokes `for_tenant` every time a tenant `AllowThem` handle is
34/// built. State that is expensive to construct (DB pools, HTTP
35/// clients) belongs on the factory itself, not on the per-tenant
36/// sink.
37pub trait EventSinkFactory: Send + Sync {
38    fn for_tenant(&self, tenant_id: TenantId) -> Arc<dyn EventSink>;
39}
40
41/// Factory that returns a fresh [`LoggingEventSink`] for every tenant,
42/// ignoring the id. Convenient for dev binaries and tests that want
43/// the factory wiring without webhook delivery.
44pub struct LoggingEventSinkFactory;
45
46impl EventSinkFactory for LoggingEventSinkFactory {
47    fn for_tenant(&self, _tenant_id: TenantId) -> Arc<dyn EventSink> {
48        Arc::new(LoggingEventSink)
49    }
50}
51
52// ---------------------------------------------------------------------------
53// WebhookEventSink — synchronous outbox writer
54// ---------------------------------------------------------------------------
55
56/// `EventSink` implementation that writes one `webhook_deliveries` row per
57/// matching `tenant_webhooks` subscription. Bound to a single tenant.
58///
59/// The sink does **not** perform HTTP. Outbound delivery is the
60/// `WebhookWorker`'s responsibility (Task 5); this type's contract is to
61/// transactionally fan-out into the durable outbox. Any failure is logged
62/// at `warn` and dropped — `EventSink::emit` returns `()` so the auth
63/// operation that produced the event is never blocked or rolled back by
64/// a webhook bookkeeping issue.
65pub struct WebhookEventSink {
66    control_pool: SqlitePool,
67    tenant_id: TenantId,
68}
69
70impl WebhookEventSink {
71    pub fn new(control_pool: SqlitePool, tenant_id: TenantId) -> Self {
72        Self {
73            control_pool,
74            tenant_id,
75        }
76    }
77}
78
79impl EventSink for WebhookEventSink {
80    fn emit<'a>(&'a self, event: &'a AuthEvent) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
81        Box::pin(async move {
82            if let Err(e) = self.emit_inner(event).await {
83                tracing::warn!(
84                    tenant_id = %self.tenant_id.as_uuid(),
85                    event_type = %event.event_type,
86                    event_id = %event.event_id,
87                    error = %e,
88                    "webhook sink: failed to record delivery"
89                );
90            }
91        })
92    }
93}
94
95impl WebhookEventSink {
96    async fn emit_inner(&self, event: &AuthEvent) -> Result<(), sqlx::Error> {
97        // Find every enabled webhook subscribed to this event_type.
98        // `event_types` is a JSON array; SQLite's json1 extension is
99        // available in every reasonable build (verified — sqlx-cli relies
100        // on it elsewhere in this repo). The EXISTS subquery short-circuits
101        // when the array contains the event_type.
102        let webhook_ids: Vec<(Vec<u8>,)> = sqlx::query_as(
103            "SELECT id FROM tenant_webhooks \
104             WHERE tenant_id = ?1 AND enabled = 1 \
105               AND EXISTS (SELECT 1 FROM json_each(event_types) WHERE value = ?2)",
106        )
107        .bind(self.tenant_id.as_bytes())
108        .bind(&event.event_type)
109        .fetch_all(&self.control_pool)
110        .await?;
111
112        if webhook_ids.is_empty() {
113            return Ok(());
114        }
115
116        // Serialise the payload once; same bytes go to every subscriber.
117        let payload = serde_json::to_string(event).map_err(|e| {
118            sqlx::Error::Decode(Box::new(std::io::Error::other(format!(
119                "failed to serialise AuthEvent: {e}"
120            ))))
121        })?;
122        let event_id_str = event.event_id.to_string();
123
124        // Insert one row per matching webhook. The (webhook_id, event_id)
125        // unique index makes re-emit of the same source event idempotent
126        // here — useful if a future emit-retry path is added.
127        for (webhook_id,) in webhook_ids {
128            let delivery_id = Uuid::now_v7();
129            sqlx::query(
130                "INSERT OR IGNORE INTO webhook_deliveries \
131                     (id, tenant_id, webhook_id, event_id, event_type, \
132                      payload, status, attempts, next_retry_at) \
133                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', 0, NULL)",
134            )
135            .bind(delivery_id.as_bytes().as_slice())
136            .bind(self.tenant_id.as_bytes())
137            .bind(webhook_id.as_slice())
138            .bind(&event_id_str)
139            .bind(&event.event_type)
140            .bind(&payload)
141            .execute(&self.control_pool)
142            .await?;
143        }
144        Ok(())
145    }
146}
147
148/// Factory that hands out [`WebhookEventSink`] per tenant, sharing the
149/// underlying control-DB pool. Construct once at SaaS-binary startup
150/// and place into `TenantBuilderConfig.event_sink_factory`.
151pub struct WebhookEventSinkFactory {
152    control_pool: SqlitePool,
153}
154
155impl WebhookEventSinkFactory {
156    pub fn new(control_pool: SqlitePool) -> Self {
157        Self { control_pool }
158    }
159}
160
161impl EventSinkFactory for WebhookEventSinkFactory {
162    fn for_tenant(&self, tenant_id: TenantId) -> Arc<dyn EventSink> {
163        Arc::new(WebhookEventSink::new(self.control_pool.clone(), tenant_id))
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::control_db::ControlDb;
171    use crate::control_db::tests::test_pool;
172    use allowthem_core::AuthEvent;
173    use uuid::Uuid;
174
175    fn assert_obj_safe(_: &dyn EventSinkFactory) {}
176
177    #[test]
178    fn logging_factory_is_object_safe_and_returns_a_sink() {
179        let f = LoggingEventSinkFactory;
180        assert_obj_safe(&f);
181        let _sink = f.for_tenant(TenantId::from(Uuid::now_v7()));
182    }
183
184    #[tokio::test]
185    async fn logging_factory_sink_emits_without_panic() {
186        let sink = LoggingEventSinkFactory.for_tenant(TenantId::from(Uuid::now_v7()));
187        let event = AuthEvent::new("test", None, serde_json::json!({}));
188        sink.emit(&event).await;
189    }
190
191    // --- WebhookEventSink helpers + tests --------------------------------
192
193    /// Inserts an `active` tenant row using the seed plan_id. Returns the
194    /// new tenant id.
195    async fn seed_tenant(db: &ControlDb, slug: &str) -> TenantId {
196        let plan_id: Vec<u8> = sqlx::query_scalar("SELECT id FROM tenant_plans LIMIT 1")
197            .fetch_one(db.pool())
198            .await
199            .unwrap();
200        let tid = Uuid::now_v7();
201        let db_path = format!("{slug}.db");
202        sqlx::query(
203            "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
204             VALUES (?, ?, ?, ?, ?, 'active', ?)",
205        )
206        .bind(tid.as_bytes().as_ref())
207        .bind(slug)
208        .bind(slug)
209        .bind(format!("owner-{slug}@example.com"))
210        .bind(&plan_id)
211        .bind(&db_path)
212        .execute(db.pool())
213        .await
214        .unwrap();
215        TenantId::from(tid)
216    }
217
218    /// Inserts a `tenant_webhooks` row and returns its id (BLOB bytes).
219    async fn seed_webhook(
220        db: &ControlDb,
221        tenant_id: TenantId,
222        url: &str,
223        event_types: &[&str],
224        enabled: bool,
225    ) -> Vec<u8> {
226        let id = Uuid::now_v7();
227        let event_types_json = serde_json::to_string(event_types).unwrap();
228        sqlx::query(
229            "INSERT INTO tenant_webhooks (id, tenant_id, url, secret_key, event_types, enabled) \
230             VALUES (?, ?, ?, ?, ?, ?)",
231        )
232        .bind(id.as_bytes().as_ref())
233        .bind(tenant_id.as_bytes())
234        .bind(url)
235        .bind("test-secret")
236        .bind(event_types_json)
237        .bind(if enabled { 1_i64 } else { 0_i64 })
238        .execute(db.pool())
239        .await
240        .unwrap();
241        id.as_bytes().to_vec()
242    }
243
244    async fn deliveries_for_tenant(
245        db: &ControlDb,
246        tenant_id: TenantId,
247    ) -> Vec<(Vec<u8>, String, String, String, i64)> {
248        sqlx::query_as::<_, (Vec<u8>, String, String, String, i64)>(
249            "SELECT webhook_id, event_id, event_type, status, attempts \
250             FROM webhook_deliveries WHERE tenant_id = ? ORDER BY created_at ASC",
251        )
252        .bind(tenant_id.as_bytes())
253        .fetch_all(db.pool())
254        .await
255        .unwrap()
256    }
257
258    #[tokio::test]
259    async fn emit_with_no_matching_webhook_inserts_nothing() {
260        let db = ControlDb::new(test_pool().await).await.unwrap();
261        let tenant = seed_tenant(&db, "acme").await;
262        // Webhook subscribes to a different event type.
263        seed_webhook(
264            &db,
265            tenant,
266            "https://acme.example/hook",
267            &["session.created"],
268            true,
269        )
270        .await;
271
272        let sink = WebhookEventSink::new(db.pool().clone(), tenant);
273        let event = AuthEvent::new("user.created", None, serde_json::json!({}));
274        sink.emit(&event).await;
275
276        assert!(deliveries_for_tenant(&db, tenant).await.is_empty());
277    }
278
279    #[tokio::test]
280    async fn emit_inserts_one_pending_row_per_matching_webhook() {
281        let db = ControlDb::new(test_pool().await).await.unwrap();
282        let tenant = seed_tenant(&db, "acme").await;
283        let h1 = seed_webhook(
284            &db,
285            tenant,
286            "https://h1.example/hook",
287            &["user.created"],
288            true,
289        )
290        .await;
291        let h2 = seed_webhook(
292            &db,
293            tenant,
294            "https://h2.example/hook",
295            &["user.created", "session.created"],
296            true,
297        )
298        .await;
299        // Decoy: subscribed to a different type.
300        seed_webhook(
301            &db,
302            tenant,
303            "https://decoy.example/hook",
304            &["role.assigned"],
305            true,
306        )
307        .await;
308
309        let sink = WebhookEventSink::new(db.pool().clone(), tenant);
310        let event = AuthEvent::new("user.created", None, serde_json::json!({"k": "v"}));
311        let event_id_str = event.event_id.to_string();
312        sink.emit(&event).await;
313
314        let rows = deliveries_for_tenant(&db, tenant).await;
315        assert_eq!(rows.len(), 2, "two matching webhooks → two delivery rows");
316        let webhook_ids: std::collections::HashSet<Vec<u8>> =
317            rows.iter().map(|r| r.0.clone()).collect();
318        assert!(webhook_ids.contains(&h1));
319        assert!(webhook_ids.contains(&h2));
320        for (_, event_id, event_type, status, attempts) in &rows {
321            assert_eq!(
322                event_id, &event_id_str,
323                "all rows share the source event_id"
324            );
325            assert_eq!(event_type, "user.created");
326            assert_eq!(status, "pending");
327            assert_eq!(*attempts, 0);
328        }
329    }
330
331    #[tokio::test]
332    async fn emit_skips_disabled_webhook() {
333        let db = ControlDb::new(test_pool().await).await.unwrap();
334        let tenant = seed_tenant(&db, "acme").await;
335        seed_webhook(
336            &db,
337            tenant,
338            "https://h1.example/hook",
339            &["user.created"],
340            false,
341        )
342        .await;
343
344        let sink = WebhookEventSink::new(db.pool().clone(), tenant);
345        let event = AuthEvent::new("user.created", None, serde_json::json!({}));
346        sink.emit(&event).await;
347
348        assert!(deliveries_for_tenant(&db, tenant).await.is_empty());
349    }
350
351    #[tokio::test]
352    async fn emit_re_emit_same_event_id_is_idempotent() {
353        let db = ControlDb::new(test_pool().await).await.unwrap();
354        let tenant = seed_tenant(&db, "acme").await;
355        seed_webhook(
356            &db,
357            tenant,
358            "https://h1.example/hook",
359            &["user.created"],
360            true,
361        )
362        .await;
363
364        let sink = WebhookEventSink::new(db.pool().clone(), tenant);
365        let event = AuthEvent::new("user.created", None, serde_json::json!({}));
366        sink.emit(&event).await;
367        sink.emit(&event).await;
368
369        let rows = deliveries_for_tenant(&db, tenant).await;
370        assert_eq!(
371            rows.len(),
372            1,
373            "(webhook_id, event_id) UNIQUE makes re-emit a no-op"
374        );
375    }
376
377    #[tokio::test]
378    async fn emit_payload_round_trips_event_fields() {
379        let db = ControlDb::new(test_pool().await).await.unwrap();
380        let tenant = seed_tenant(&db, "acme").await;
381        seed_webhook(
382            &db,
383            tenant,
384            "https://h1.example/hook",
385            &["user.created"],
386            true,
387        )
388        .await;
389
390        let sink = WebhookEventSink::new(db.pool().clone(), tenant);
391        let event = AuthEvent::new("user.created", None, serde_json::json!({"k": "v"}));
392        let expected_event_id = event.event_id.to_string();
393        sink.emit(&event).await;
394
395        let payload: String = sqlx::query_scalar(
396            "SELECT payload FROM webhook_deliveries WHERE tenant_id = ? LIMIT 1",
397        )
398        .bind(tenant.as_bytes())
399        .fetch_one(db.pool())
400        .await
401        .unwrap();
402        let v: serde_json::Value = serde_json::from_str(&payload).unwrap();
403        assert_eq!(v["event_id"], expected_event_id);
404        assert_eq!(v["event_type"], "user.created");
405        assert_eq!(v["data"]["k"], "v");
406    }
407
408    #[tokio::test]
409    async fn factory_returns_sink_bound_to_supplied_tenant() {
410        let db = ControlDb::new(test_pool().await).await.unwrap();
411        let tenant_a = seed_tenant(&db, "acme").await;
412        let tenant_b = seed_tenant(&db, "globex").await;
413        seed_webhook(
414            &db,
415            tenant_a,
416            "https://a.example/hook",
417            &["user.created"],
418            true,
419        )
420        .await;
421        seed_webhook(
422            &db,
423            tenant_b,
424            "https://b.example/hook",
425            &["user.created"],
426            true,
427        )
428        .await;
429
430        let factory = WebhookEventSinkFactory::new(db.pool().clone());
431        let sink_for_a = factory.for_tenant(tenant_a);
432        let event = AuthEvent::new("user.created", None, serde_json::json!({}));
433        sink_for_a.emit(&event).await;
434
435        let a_rows = deliveries_for_tenant(&db, tenant_a).await;
436        let b_rows = deliveries_for_tenant(&db, tenant_b).await;
437        assert_eq!(a_rows.len(), 1);
438        assert!(
439            b_rows.is_empty(),
440            "tenant_b should not see tenant_a's events"
441        );
442    }
443
444    #[tokio::test]
445    async fn emit_swallows_pool_failure_so_auth_path_is_unaffected() {
446        // Closes the EventSink contract: emit() returns () even when the
447        // underlying control-DB write fails. Otherwise a webhook
448        // bookkeeping issue could roll back or block the auth operation
449        // that produced the event.
450        let db = ControlDb::new(test_pool().await).await.unwrap();
451        let tenant = seed_tenant(&db, "acme").await;
452        seed_webhook(
453            &db,
454            tenant,
455            "https://h1.example/hook",
456            &["user.created"],
457            true,
458        )
459        .await;
460
461        // Build a sink against a clone of the pool, then close the original
462        // — sqlx pool semantics mean any further query will return a
463        // closed-pool error.
464        let sink = WebhookEventSink::new(db.pool().clone(), tenant);
465        db.pool().close().await;
466
467        let event = AuthEvent::new("user.created", None, serde_json::json!({}));
468        // The .await here must complete cleanly. A panic or a hang would
469        // be a regression of the contract.
470        sink.emit(&event).await;
471    }
472}