1use 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
30pub trait EventSinkFactory: Send + Sync {
38 fn for_tenant(&self, tenant_id: TenantId) -> Arc<dyn EventSink>;
39}
40
41pub 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
52pub 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 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 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 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
148pub 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 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 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 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 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 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 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 sink.emit(&event).await;
471 }
472}