Skip to main content

teamcraft/services/
webhooks.rs

1//! Outgoing event webhooks: administration and the events.
2//!
3//! Administrators register webhooks (URL, events, optional project group).
4//! The services call [`emit`] inside the transaction of the event, which
5//! writes one delivery per matching active webhook into the outbox
6//! `webhook_deliveries`; `crate::webhooks` sends them. A rolled back event
7//! leaves no delivery behind.
8//!
9//! Every payload has the same envelope – `event`, `id` (one per event, the
10//! same for every webhook), `occurred_at`, `actor.username`, `instance` – plus
11//! the objects of the event (`revision`, `change`, `change_request`, …) with
12//! their identifiers, `web_url` and `api_url`. File contents and secrets are
13//! never part of a payload.
14//!
15//! | Event                     | Emitted by                                   | Objects |
16//! |---------------------------|----------------------------------------------|---------|
17//! | `revision.released`       | the last approval of a change, per revision  | `revision`, `change` |
18//! | `revision.obsoleted`      | marking a released revision obsolete         | `revision` |
19//! | `change.submitted`        | submitting a change for review               | `change`, `revisions` |
20//! | `change.approved`         | every approval of a reviewer                 | `change`, `review`, `revisions` |
21//! | `change.rejected`         | a reviewer's rejection (rejects the change)  | `change`, `review`, `revisions` |
22//! | `change.released`         | the last approval                            | `change`, `revisions` |
23//! | `change_request.created`  | raising a change request                     | `change_request`, `items` |
24//! | `change_request.decided`  | accepting, rejecting or deferring a request  | `change_request`, `decision` |
25//! | `simulation.result_added` | adding or importing simulation result values | `revision`, `result` or `import` |
26
27use chrono::{DateTime, Utc};
28use serde::Serialize;
29use serde_json::{json, Map, Value};
30use sqlx::{PgConnection, PgPool};
31use uuid::Uuid;
32
33use crate::{
34    auth::{self, Actor},
35    domain::{self, Role, WebhookEvent},
36    error::{AppError, AppResult},
37    models::{Change, ChangeRequest, Revision},
38    services::audit,
39    util,
40    webhooks::TargetPolicy,
41};
42
43/// Prefix of generated webhook secrets.
44pub const SECRET_PREFIX: &str = "tc_whsec_";
45/// Most webhooks of one instance.
46pub const MAX_WEBHOOKS: i64 = 100;
47
48/// A webhook as shown to administrators – without its secret.
49#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
50pub struct Webhook {
51    pub id: Uuid,
52    pub name: String,
53    pub url: String,
54    /// `tc_whsec_…1a2b`: enough to tell secrets apart, useless for signing.
55    pub secret_hint: String,
56    pub events: Vec<String>,
57    pub project_id: Option<Uuid>,
58    pub project_code: Option<String>,
59    pub active: bool,
60    pub created_by: Option<Uuid>,
61    pub created_by_name: Option<String>,
62    pub created_at: DateTime<Utc>,
63    pub updated_at: DateTime<Utc>,
64    pub secret_rotated_at: DateTime<Utc>,
65    /// Deliveries waiting to be sent or retried.
66    pub pending: i64,
67    /// Deliveries that gave up during the last 7 days.
68    pub failed_recently: i64,
69    /// Status and time of the latest delivery.
70    pub last_status: Option<String>,
71    pub last_delivery_at: Option<DateTime<Utc>>,
72}
73
74const WEBHOOK_SELECT: &str = "
75    SELECT w.id, w.name, w.url, w.secret_hint, w.events, w.project_id, p.code AS project_code,
76           w.active, w.created_by, u.display_name AS created_by_name,
77           w.created_at, w.updated_at, w.secret_rotated_at,
78           (SELECT COUNT(*) FROM webhook_deliveries d
79             WHERE d.webhook_id = w.id AND d.status = 'pending') AS pending,
80           (SELECT COUNT(*) FROM webhook_deliveries d
81             WHERE d.webhook_id = w.id AND d.status = 'failed'
82               AND d.updated_at > now() - interval '7 days') AS failed_recently,
83           last.status AS last_status, last.created_at AS last_delivery_at
84    FROM webhooks w
85    LEFT JOIN projects p ON p.id = w.project_id
86    LEFT JOIN users u ON u.id = w.created_by
87    LEFT JOIN LATERAL (
88        SELECT d.status, d.created_at FROM webhook_deliveries d
89        WHERE d.webhook_id = w.id ORDER BY d.created_at DESC LIMIT 1
90    ) last ON TRUE";
91
92/// One entry of the outbox.
93#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
94pub struct Delivery {
95    /// Sent as `X-TeamCraft-Delivery`.
96    pub id: Uuid,
97    pub webhook_id: Uuid,
98    /// The payload's `id`.
99    pub event_id: Uuid,
100    pub event: String,
101    pub payload: Value,
102    /// `pending`, `sent` or `failed`.
103    pub status: String,
104    pub attempts: i32,
105    pub next_attempt_at: DateTime<Utc>,
106    pub last_status_code: Option<i32>,
107    pub last_error: String,
108    pub created_at: DateTime<Utc>,
109    pub updated_at: DateTime<Utc>,
110    pub delivered_at: Option<DateTime<Utc>>,
111}
112
113/// SQL guard: the delivery is not one of a webhook scoped to a group the need-to-know
114/// policy hides from the administrator (the payload holds the group's content);
115/// `$n` = the hidden groups.
116fn hook_free(n: usize) -> String {
117    format!(
118        "NOT EXISTS (SELECT 1 FROM webhooks hw WHERE hw.id = webhook_id AND hw.project_id = ANY(${n}))"
119    )
120}
121
122const DELIVERY_SELECT: &str = "
123    SELECT id, webhook_id, event_id, event, payload, status, attempts, next_attempt_at,
124           last_status_code, last_error, created_at, updated_at, delivered_at
125    FROM webhook_deliveries";
126
127/// What an administrator enters.
128#[derive(Clone, Debug)]
129pub struct WebhookInput {
130    pub name: String,
131    pub url: String,
132    pub events: Vec<String>,
133    pub project_id: Option<Uuid>,
134    pub active: bool,
135}
136
137struct Valid {
138    name: String,
139    url: String,
140    events: Vec<String>,
141}
142
143fn validate(policy: &TargetPolicy, input: &WebhookInput) -> AppResult<Valid> {
144    let name = domain::require_text("Name", &input.name, 100)?;
145    if input.url.trim().chars().count() > 2000 {
146        return Err(AppError::BadRequest(
147            "The URL is longer than 2000 characters.".into(),
148        ));
149    }
150    let url = policy
151        .check_url(&input.url)
152        .map_err(AppError::BadRequest)?
153        .to_string();
154    let mut events = Vec::new();
155    for raw in &input.events {
156        let event = WebhookEvent::parse(raw)
157            .ok_or_else(|| AppError::BadRequest(format!("Unknown event '{}'.", raw.trim())))?;
158        if !events.contains(&event) {
159            events.push(event);
160        }
161    }
162    if events.is_empty() {
163        return Err(AppError::BadRequest(
164            "Choose at least one event for the webhook.".into(),
165        ));
166    }
167    // Stored in the canonical order of the event list.
168    let events = WebhookEvent::ALL
169        .iter()
170        .filter(|e| events.contains(e))
171        .map(|e| e.as_str().to_string())
172        .collect();
173    Ok(Valid { name, url, events })
174}
175
176/// The host of a URL (audit records never contain the full URL: its path or
177/// query may carry a token of the receiver).
178fn host_of(url: &str) -> String {
179    reqwest::Url::parse(url)
180        .ok()
181        .and_then(|u| u.host_str().map(str::to_string))
182        .unwrap_or_default()
183}
184
185fn new_secret() -> (String, String) {
186    let secret = auth::generate_token(SECRET_PREFIX);
187    let hint = format!("{SECRET_PREFIX}…{}", &secret[secret.len() - 4..]);
188    (secret, hint)
189}
190
191async fn ensure_project(
192    conn: &mut PgConnection,
193    actor: &Actor,
194    project_id: Option<Uuid>,
195) -> AppResult<()> {
196    if let Some(id) = project_id {
197        // a group the need-to-know policy hides from the administrator answers like a missing one
198        if actor.hidden_projects.contains(&id) {
199            return Err(AppError::BadRequest("Unknown project group.".into()));
200        }
201        let exists =
202            sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1)")
203                .bind(id)
204                .fetch_one(&mut *conn)
205                .await?;
206        if !exists {
207            return Err(AppError::BadRequest("Unknown project group.".into()));
208        }
209    }
210    Ok(())
211}
212
213pub async fn list(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Webhook>> {
214    actor.require(Role::Admin)?;
215    Ok(sqlx::query_as::<_, Webhook>(&format!(
216        "{WEBHOOK_SELECT} ORDER BY lower(w.name), w.created_at"
217    ))
218    .fetch_all(pool)
219    .await?)
220}
221
222pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Webhook> {
223    actor.require(Role::Admin)?;
224    fetch(pool, id).await
225}
226
227async fn fetch(pool: &PgPool, id: Uuid) -> AppResult<Webhook> {
228    sqlx::query_as::<_, Webhook>(&format!("{WEBHOOK_SELECT} WHERE w.id = $1"))
229        .bind(id)
230        .fetch_optional(pool)
231        .await?
232        .ok_or_else(|| AppError::not_found("Webhook not found."))
233}
234
235/// Locks a webhook row; returns its name and URL.
236async fn lock(conn: &mut PgConnection, id: Uuid) -> AppResult<(String, String, bool)> {
237    sqlx::query_as::<_, (String, String, bool)>(
238        "SELECT name, url, active FROM webhooks WHERE id = $1 FOR UPDATE",
239    )
240    .bind(id)
241    .fetch_optional(&mut *conn)
242    .await?
243    .ok_or_else(|| AppError::not_found("Webhook not found."))
244}
245
246/// Registers a webhook. Returns it and its secret – the only time the
247/// secret leaves the server.
248///
249/// # Invariants
250///
251/// * **Permission:** administrator (403). Name, URL (target policy), events
252///   and project group are validated (400).
253/// * **Locks:** the `webhooks` table (`SHARE ROW EXCLUSIVE`), so concurrent
254///   creations cannot pass the limit; at the limit 409.
255/// * **Data:** the secret is generated here and returned once; the audit
256///   record holds the host of the URL, never the full URL.
257/// * **Audit:** `webhook.create`.
258/// * **Notifications / webhooks:** none.
259/// * **Atomic:** one transaction.
260pub async fn create(
261    pool: &PgPool,
262    actor: &Actor,
263    policy: &TargetPolicy,
264    input: WebhookInput,
265) -> AppResult<(Webhook, String)> {
266    actor.require(Role::Admin)?;
267    let v = validate(policy, &input)?;
268    let mut tx = pool.begin().await?;
269    // Serialises concurrent creations for the limit check.
270    sqlx::query("LOCK TABLE webhooks IN SHARE ROW EXCLUSIVE MODE")
271        .execute(&mut *tx)
272        .await?;
273    let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webhooks")
274        .fetch_one(&mut *tx)
275        .await?;
276    if count >= MAX_WEBHOOKS {
277        return Err(AppError::Conflict(format!(
278            "There are already {MAX_WEBHOOKS} webhooks; delete one first."
279        )));
280    }
281    ensure_project(&mut tx, actor, input.project_id).await?;
282    let (secret, hint) = new_secret();
283    let id = Uuid::new_v4();
284    sqlx::query(
285        "INSERT INTO webhooks (id, name, url, secret, secret_hint, events, project_id, active, created_by)
286         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
287    )
288    .bind(id)
289    .bind(&v.name)
290    .bind(&v.url)
291    .bind(&secret)
292    .bind(&hint)
293    .bind(&v.events)
294    .bind(input.project_id)
295    .bind(input.active)
296    .bind(actor.id)
297    .execute(&mut *tx)
298    .await?;
299    audit::record(
300        &mut tx,
301        Some(actor),
302        "webhook.create",
303        "webhook",
304        Some(id),
305        format!("Created webhook {} → {}", v.name, host_of(&v.url)),
306        json!({ "host": host_of(&v.url), "events": v.events, "project_id": input.project_id,
307                "active": input.active }),
308    )
309    .await?;
310    tx.commit().await?;
311    Ok((fetch(pool, id).await?, secret))
312}
313
314/// Changes name, URL, events, project group and the active flag. Pending
315/// deliveries of a webhook that is switched off are given up.
316///
317/// # Invariants
318///
319/// * **Permission:** administrator (403); unknown webhook is "not found";
320///   input is validated as in `create` (400).
321/// * **Locks:** the webhook row (`FOR UPDATE`).
322/// * **Data:** switching an active webhook off marks its pending deliveries
323///   as failed.
324/// * **Audit:** `webhook.update` (host of the URL, never the full URL).
325/// * **Notifications / webhooks:** none.
326/// * **Atomic:** one transaction.
327pub async fn update(
328    pool: &PgPool,
329    actor: &Actor,
330    policy: &TargetPolicy,
331    id: Uuid,
332    input: WebhookInput,
333) -> AppResult<Webhook> {
334    actor.require(Role::Admin)?;
335    let v = validate(policy, &input)?;
336    let mut tx = pool.begin().await?;
337    let (old_name, old_url, was_active) = lock(&mut tx, id).await?;
338    // a webhook scoped to a hidden group cannot be redirected either
339    let old_project: Option<Uuid> =
340        sqlx::query_scalar("SELECT project_id FROM webhooks WHERE id = $1")
341            .bind(id)
342            .fetch_one(&mut *tx)
343            .await?;
344    if old_project.is_some_and(|p| actor.hidden_projects.contains(&p)) {
345        return Err(AppError::not_found("Webhook not found."));
346    }
347    ensure_project(&mut tx, actor, input.project_id).await?;
348    sqlx::query(
349        "UPDATE webhooks SET name = $2, url = $3, events = $4, project_id = $5, active = $6,
350                updated_at = now()
351         WHERE id = $1",
352    )
353    .bind(id)
354    .bind(&v.name)
355    .bind(&v.url)
356    .bind(&v.events)
357    .bind(input.project_id)
358    .bind(input.active)
359    .execute(&mut *tx)
360    .await?;
361    let dropped = if was_active && !input.active {
362        sqlx::query(
363            "UPDATE webhook_deliveries
364             SET status = 'failed', last_error = 'The webhook was deactivated.', updated_at = now()
365             WHERE webhook_id = $1 AND status = 'pending'",
366        )
367        .bind(id)
368        .execute(&mut *tx)
369        .await?
370        .rows_affected()
371    } else {
372        0
373    };
374    audit::record(
375        &mut tx,
376        Some(actor),
377        "webhook.update",
378        "webhook",
379        Some(id),
380        format!(
381            "Changed webhook {}{}",
382            v.name,
383            match (was_active, input.active) {
384                (true, false) => " (deactivated)",
385                (false, true) => " (activated)",
386                _ => "",
387            }
388        ),
389        json!({ "name_before": old_name, "host_before": host_of(&old_url), "host": host_of(&v.url),
390                "url_changed": old_url != v.url, "events": v.events, "project_id": input.project_id,
391                "active": input.active, "pending_given_up": dropped }),
392    )
393    .await?;
394    tx.commit().await?;
395    fetch(pool, id).await
396}
397
398/// Deletes a webhook together with its deliveries.
399///
400/// # Invariants
401///
402/// * **Permission:** administrator (403); unknown webhook is "not found".
403/// * **Locks:** the webhook row (`FOR UPDATE`).
404/// * **Data:** the deliveries of the webhook are deleted with it.
405/// * **Audit:** `webhook.delete`.
406/// * **Notifications / webhooks:** none.
407/// * **Atomic:** one transaction.
408pub async fn delete(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<String> {
409    actor.require(Role::Admin)?;
410    let mut tx = pool.begin().await?;
411    let (name, url, _) = lock(&mut tx, id).await?;
412    let deliveries = sqlx::query("DELETE FROM webhook_deliveries WHERE webhook_id = $1")
413        .bind(id)
414        .execute(&mut *tx)
415        .await?
416        .rows_affected();
417    sqlx::query("DELETE FROM webhooks WHERE id = $1")
418        .bind(id)
419        .execute(&mut *tx)
420        .await?;
421    audit::record(
422        &mut tx,
423        Some(actor),
424        "webhook.delete",
425        "webhook",
426        Some(id),
427        format!("Deleted webhook {name} → {}", host_of(&url)),
428        json!({ "host": host_of(&url), "deliveries": deliveries }),
429    )
430    .await?;
431    tx.commit().await?;
432    Ok(name)
433}
434
435/// Replaces the secret. Returns the webhook and the new secret (shown once).
436/// Deliveries that are still pending are signed with the new secret.
437///
438/// # Invariants
439///
440/// * **Permission:** administrator (403); unknown webhook is "not found".
441/// * **Locks:** the webhook row (`FOR UPDATE`).
442/// * **Data:** the new secret is returned once; the audit record holds only
443///   its hint.
444/// * **Audit:** `webhook.rotate_secret`.
445/// * **Notifications / webhooks:** none.
446/// * **Atomic:** one transaction.
447pub async fn rotate_secret(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<(Webhook, String)> {
448    actor.require(Role::Admin)?;
449    let mut tx = pool.begin().await?;
450    let (name, _, _) = lock(&mut tx, id).await?;
451    let (secret, hint) = new_secret();
452    sqlx::query(
453        "UPDATE webhooks SET secret = $2, secret_hint = $3, secret_rotated_at = now(), updated_at = now()
454         WHERE id = $1",
455    )
456    .bind(id)
457    .bind(&secret)
458    .bind(&hint)
459    .execute(&mut *tx)
460    .await?;
461    audit::record(
462        &mut tx,
463        Some(actor),
464        "webhook.rotate_secret",
465        "webhook",
466        Some(id),
467        format!("Rotated the secret of webhook {name}"),
468        json!({ "secret_hint": hint }),
469    )
470    .await?;
471    tx.commit().await?;
472    Ok((fetch(pool, id).await?, secret))
473}
474
475/// Queues a `ping` delivery to an active webhook; returns its id (the
476/// caller may send it at once with `Dispatcher::deliver_now`).
477///
478/// # Invariants
479///
480/// * **Permission:** administrator (403); unknown webhook is "not found";
481///   an inactive webhook is refused (409).
482/// * **Locks:** the webhook row (`FOR UPDATE`).
483/// * **Audit:** `webhook.test`.
484/// * **Notifications / webhooks:** inserts one pending `ping` delivery (it
485///   does not go through `emit`).
486/// * **Atomic:** one transaction.
487pub async fn send_test(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Uuid> {
488    actor.require(Role::Admin)?;
489    let mut tx = pool.begin().await?;
490    let (name, _, active) = lock(&mut tx, id).await?;
491    if !active {
492        return Err(AppError::Conflict(format!(
493            "Webhook {name} is inactive; activate it before sending a test."
494        )));
495    }
496    let event_id = Uuid::new_v4();
497    let mut payload = envelope("ping", event_id, Some(actor));
498    payload.insert(
499        "webhook".into(),
500        json!({ "id": id, "name": name, "web_url": format!("/admin/webhooks/{id}"),
501                "api_url": format!("/api/v1/webhooks/{id}") }),
502    );
503    payload.insert(
504        "message".into(),
505        json!("Test delivery – the webhook is reachable."),
506    );
507    let delivery_id = Uuid::new_v4();
508    sqlx::query(
509        "INSERT INTO webhook_deliveries (id, webhook_id, event_id, event, payload)
510         VALUES ($1, $2, $3, 'ping', $4)",
511    )
512    .bind(delivery_id)
513    .bind(id)
514    .bind(event_id)
515    .bind(Value::Object(payload))
516    .execute(&mut *tx)
517    .await?;
518    audit::record(
519        &mut tx,
520        Some(actor),
521        "webhook.test",
522        "webhook",
523        Some(id),
524        format!("Sent a test delivery to webhook {name}"),
525        json!({ "delivery_id": delivery_id }),
526    )
527    .await?;
528    tx.commit().await?;
529    Ok(delivery_id)
530}
531
532/// Schedules a failed (or waiting) delivery again, now, with a fresh set of
533/// attempts. The delivery id stays the same, so receivers can deduplicate.
534///
535/// # Invariants
536///
537/// * **Permission:** administrator (403); unknown delivery is "not found";
538///   a delivery already sent (409) or of an inactive webhook (409) is
539///   refused.
540/// * **Locks:** the delivery row (`FOR UPDATE`).
541/// * **Data:** status back to pending, attempts reset to 0, due now.
542/// * **Audit:** `webhook.retry`.
543/// * **Notifications / webhooks:** none.
544/// * **Atomic:** one transaction.
545pub async fn retry(pool: &PgPool, actor: &Actor, delivery_id: Uuid) -> AppResult<Delivery> {
546    actor.require(Role::Admin)?;
547    let mut tx = pool.begin().await?;
548    let row = sqlx::query_as::<_, (Uuid, String, String, bool)>(&format!(
549        "SELECT d.webhook_id, d.status, w.name, w.active
550         FROM webhook_deliveries d JOIN webhooks w ON w.id = d.webhook_id
551         WHERE d.id = $1 AND {} FOR UPDATE OF d",
552        crate::services::access::project_visible("w.project_id", 2)
553    ))
554    .bind(delivery_id)
555    .bind(&actor.hidden_projects)
556    .fetch_optional(&mut *tx)
557    .await?
558    .ok_or_else(|| AppError::not_found("Delivery not found."))?;
559    let (webhook_id, status, name, active) = row;
560    if status == "sent" {
561        return Err(AppError::Conflict(
562            "This delivery was already sent successfully.".into(),
563        ));
564    }
565    if !active {
566        return Err(AppError::Conflict(format!(
567            "Webhook {name} is inactive; activate it before retrying."
568        )));
569    }
570    sqlx::query(
571        "UPDATE webhook_deliveries
572         SET status = 'pending', attempts = 0, next_attempt_at = now(), updated_at = now()
573         WHERE id = $1",
574    )
575    .bind(delivery_id)
576    .execute(&mut *tx)
577    .await?;
578    audit::record(
579        &mut tx,
580        Some(actor),
581        "webhook.retry",
582        "webhook",
583        Some(webhook_id),
584        format!("Retried a delivery of webhook {name}"),
585        json!({ "delivery_id": delivery_id, "status_before": status }),
586    )
587    .await?;
588    tx.commit().await?;
589    get_delivery(pool, actor, delivery_id).await
590}
591
592/// The latest deliveries of a webhook, newest first.
593pub async fn deliveries(
594    pool: &PgPool,
595    actor: &Actor,
596    webhook_id: Uuid,
597    limit: i64,
598) -> AppResult<Vec<Delivery>> {
599    actor.require(Role::Admin)?;
600    Ok(sqlx::query_as::<_, Delivery>(&format!(
601        "{DELIVERY_SELECT} WHERE webhook_id = $1 AND {}
602         ORDER BY created_at DESC, id LIMIT $2",
603        hook_free(3)
604    ))
605    .bind(webhook_id)
606    .bind(limit.clamp(1, 200))
607    .bind(&actor.hidden_projects)
608    .fetch_all(pool)
609    .await?)
610}
611
612pub async fn get_delivery(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Delivery> {
613    actor.require(Role::Admin)?;
614    sqlx::query_as::<_, Delivery>(&format!(
615        "{DELIVERY_SELECT} WHERE id = $1 AND {}",
616        hook_free(2)
617    ))
618    .bind(id)
619    .bind(&actor.hidden_projects)
620    .fetch_optional(pool)
621    .await?
622    .ok_or_else(|| AppError::not_found("Delivery not found."))
623}
624
625// ---------------------------------------------------------------------------
626// Events
627// ---------------------------------------------------------------------------
628
629fn envelope(event: &str, id: Uuid, actor: Option<&Actor>) -> Map<String, Value> {
630    let mut map = Map::new();
631    map.insert("event".into(), json!(event));
632    map.insert("id".into(), json!(id));
633    map.insert(
634        "occurred_at".into(),
635        json!(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
636    );
637    map.insert(
638        "actor".into(),
639        json!({ "username": actor.map(|a| a.username.as_str()).unwrap_or("system") }),
640    );
641    map
642}
643
644/// Builds the payload of an event: the envelope plus `objects` (a JSON
645/// object whose keys are added at the top level).
646pub fn payload(event: WebhookEvent, id: Uuid, actor: Option<&Actor>, objects: Value) -> Value {
647    let mut map = envelope(event.as_str(), id, actor);
648    if let Value::Object(objects) = objects {
649        for (k, v) in objects {
650            map.entry(k).or_insert(v);
651        }
652    }
653    Value::Object(map)
654}
655
656/// Queues an event for every active webhook that subscribed to it and whose
657/// project group (if any) is one of `projects`. Call it inside the event's
658/// transaction. Returns the number of deliveries queued.
659///
660/// A webhook limited to a project group receives only the entries of a
661/// top-level `revisions` list that belong to its group (a change can hold
662/// revisions of several groups; the other groups' items are none of that
663/// receiver's business).
664///
665/// Read access: each webhook is a reader that sees its own project group
666/// and the internal groups (a global webhook: the internal groups only), so
667/// an event that touches a restricted group reaches only webhooks scoped to
668/// that group.
669pub async fn emit(
670    conn: &mut PgConnection,
671    actor: Option<&Actor>,
672    event: WebhookEvent,
673    projects: &[Option<Uuid>],
674    objects: Value,
675) -> AppResult<u64> {
676    let projects: Vec<Uuid> = projects.iter().flatten().copied().collect();
677    let event_id = Uuid::new_v4();
678    let body = payload(event, event_id, actor, objects);
679    Ok(sqlx::query(
680        "INSERT INTO webhook_deliveries (id, webhook_id, event_id, event, projects, payload)
681         SELECT gen_random_uuid(), w.id, $1, $2, $4,
682                CASE WHEN p.code IS NULL OR jsonb_typeof($3::jsonb -> 'revisions') IS DISTINCT FROM 'array'
683                     THEN $3::jsonb
684                     ELSE jsonb_set($3::jsonb, '{revisions}', COALESCE(
685                         (SELECT jsonb_agg(r.value ORDER BY r.ordinality)
686                          FROM jsonb_array_elements($3::jsonb -> 'revisions') WITH ORDINALITY AS r
687                          WHERE r.value ->> 'project' = p.code),
688                         '[]'::jsonb))
689                END
690         FROM webhooks w
691         LEFT JOIN projects p ON p.id = w.project_id
692         WHERE w.active AND $2 = ANY(w.events)
693           AND (w.project_id IS NULL OR w.project_id = ANY($4))
694           -- the webhook as a reader: its own group and the internal groups
695           AND NOT EXISTS (SELECT 1 FROM projects rp
696                           WHERE rp.id = ANY($4) AND rp.visibility = 'restricted'
697                             AND rp.id IS DISTINCT FROM w.project_id)
698           -- a group that switched webhooks off sends nothing, scoped or not
699           AND NOT EXISTS (SELECT 1 FROM project_policies wp
700                           JOIN projects wpp ON wpp.id = wp.project_id
701                           WHERE wp.project_id = ANY($4) AND wpp.visibility = 'restricted'
702                             AND NOT wp.webhooks)",
703    )
704    .bind(event_id)
705    .bind(event.as_str())
706    .bind(body)
707    .bind(&projects)
708    .execute(&mut *conn)
709    .await?
710    .rows_affected())
711}
712
713/// One event per entry of `each` (e.g. `revision.released` for every
714/// revision of a change) in a single statement: each entry is
715/// `(project group, objects)`. Returns the number of deliveries queued.
716///
717/// `change_projects` are the project groups of the change order that the
718/// objects carry as `change` (empty: none). A receiver that may not read
719/// all of them (the change also touches another restricted group, see
720/// [`emit`]) gets the event without the `change` object.
721pub(crate) async fn emit_each(
722    conn: &mut PgConnection,
723    actor: Option<&Actor>,
724    event: WebhookEvent,
725    each: Vec<(Option<Uuid>, Value)>,
726    change_projects: &[Option<Uuid>],
727) -> AppResult<u64> {
728    if each.is_empty() {
729        return Ok(0);
730    }
731    let change_projects: Vec<Uuid> = change_projects.iter().flatten().copied().collect();
732    let mut ids = Vec::with_capacity(each.len());
733    let mut projects = Vec::with_capacity(each.len());
734    let mut bodies = Vec::with_capacity(each.len());
735    for (project, objects) in each {
736        let id = Uuid::new_v4();
737        ids.push(id);
738        projects.push(project);
739        bodies.push(payload(event, id, actor, objects));
740    }
741    Ok(sqlx::query(
742        "INSERT INTO webhook_deliveries (id, webhook_id, event_id, event, projects, payload)
743         SELECT gen_random_uuid(), w.id, e.event_id, $1,
744                array_remove(ARRAY[e.project_id], NULL)
745                  || CASE WHEN s.strip THEN '{}'::uuid[] ELSE $5::uuid[] END,
746                CASE WHEN s.strip THEN e.payload - 'change' ELSE e.payload END
747         FROM unnest($2::uuid[], $3::uuid[], $4::jsonb[]) AS e (event_id, project_id, payload)
748         JOIN webhooks w ON w.active AND $1 = ANY(w.events)
749                        AND (w.project_id IS NULL OR w.project_id = e.project_id)
750         CROSS JOIN LATERAL (
751             SELECT EXISTS (SELECT 1 FROM projects cp
752                            WHERE cp.id = ANY($5) AND cp.visibility = 'restricted'
753                              AND cp.id IS DISTINCT FROM w.project_id)
754                    OR EXISTS (SELECT 1 FROM project_policies wp
755                               JOIN projects wpp ON wpp.id = wp.project_id
756                               WHERE wp.project_id = ANY($5) AND wpp.visibility = 'restricted'
757                                 AND NOT wp.webhooks) AS strip
758         ) s
759         WHERE NOT EXISTS (SELECT 1 FROM projects rp
760                           WHERE rp.id = e.project_id AND rp.visibility = 'restricted'
761                             AND rp.id IS DISTINCT FROM w.project_id)
762           AND NOT EXISTS (SELECT 1 FROM project_policies wp
763                           JOIN projects wpp ON wpp.id = wp.project_id
764                           WHERE wp.project_id = e.project_id AND wpp.visibility = 'restricted'
765                             AND NOT wp.webhooks)",
766    )
767    .bind(event.as_str())
768    .bind(&ids)
769    .bind(&projects)
770    .bind(&bodies)
771    .bind(&change_projects)
772    .execute(&mut *conn)
773    .await?
774    .rows_affected())
775}
776
777/// Whether any active webhook subscribed to one of `events` (lets a service
778/// skip building payloads nobody receives).
779pub(crate) async fn wanted(conn: &mut PgConnection, events: &[WebhookEvent]) -> AppResult<bool> {
780    let names: Vec<&str> = events.iter().map(|e| e.as_str()).collect();
781    Ok(sqlx::query_scalar::<_, bool>(
782        "SELECT EXISTS(SELECT 1 FROM webhooks WHERE active AND events && $1::text[])",
783    )
784    .bind(&names)
785    .fetch_one(&mut *conn)
786    .await?)
787}
788
789/// A revision in a payload. `state` is the state after the event.
790pub fn revision_object(rev: &Revision, state: &str) -> Value {
791    json!({
792        "id": rev.id,
793        "item_id": rev.item_id,
794        "item_number": rev.item_number,
795        "item_type": rev.item_type,
796        "code": rev.code,
797        "label": format!("{}/{}", rev.item_number, rev.code),
798        "name": rev.name,
799        "state": state,
800        "project": rev.project_code,
801        "web_url": format!("/revisions/{}", rev.id),
802        "api_url": format!("/api/v1/items/{}/revisions/{}",
803                           util::pct_encode(&rev.item_number), util::pct_encode(&rev.code)),
804    })
805}
806
807/// A change in a payload. `status` is the status after the event.
808pub fn change_object(change: &Change, status: &str) -> Value {
809    json!({
810        "id": change.id,
811        "number": change.number,
812        "title": change.title,
813        "priority": change.priority,
814        "status": status,
815        "author": change.created_by_name,
816        "web_url": format!("/changes/{}", change.id),
817        "api_url": format!("/api/v1/changes/{}", util::pct_encode(&change.number)),
818    })
819}
820
821/// A change request in a payload (with its current status).
822pub fn request_object(request: &ChangeRequest) -> Value {
823    json!({
824        "id": request.id,
825        "number": request.number,
826        "title": request.title,
827        "reason": request.reason,
828        "priority": request.priority,
829        "status": request.status,
830        "requested_by": request.requested_by_name,
831        "due_date": request.due_date,
832        "web_url": format!("/requests/{}", request.id),
833        "api_url": format!("/api/v1/change-requests/{}", util::pct_encode(&request.number)),
834    })
835}
836
837/// The revisions of a change as payload objects (current states) and the
838/// project group of each.
839pub(crate) async fn change_revisions(
840    conn: &mut PgConnection,
841    change_id: Uuid,
842) -> AppResult<(Vec<Value>, Vec<Option<Uuid>>)> {
843    #[derive(sqlx::FromRow)]
844    struct Row {
845        id: Uuid,
846        item_id: Uuid,
847        item_number: String,
848        item_type: String,
849        code: String,
850        name: String,
851        state: String,
852        action: String,
853        project_id: Option<Uuid>,
854        project_code: Option<String>,
855    }
856    let rows = sqlx::query_as::<_, Row>(
857        "SELECT r.id, r.item_id, i.number AS item_number, i.item_type, r.code, r.name, r.state, ci.action,
858                i.project_id, p.code AS project_code
859         FROM change_items ci
860         JOIN revisions r ON r.id = ci.revision_id
861         JOIN items i ON i.id = r.item_id
862         LEFT JOIN projects p ON p.id = i.project_id
863         WHERE ci.change_id = $1
864         ORDER BY i.number, r.seq",
865    )
866    .bind(change_id)
867    .fetch_all(&mut *conn)
868    .await?;
869    let projects = rows.iter().map(|r| r.project_id).collect();
870    let objects = rows
871        .into_iter()
872        .map(|r| {
873            json!({
874                "id": r.id,
875                "item_id": r.item_id,
876                "item_number": r.item_number,
877                "item_type": r.item_type,
878                "code": r.code,
879                "label": format!("{}/{}", r.item_number, r.code),
880                "name": r.name,
881                "state": r.state,
882                "action": r.action,
883                "project": r.project_code,
884                "web_url": format!("/revisions/{}", r.id),
885                "api_url": format!("/api/v1/items/{}/revisions/{}",
886                                   util::pct_encode(&r.item_number), util::pct_encode(&r.code)),
887            })
888        })
889        .collect();
890    Ok((objects, projects))
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896
897    fn actor() -> Actor {
898        Actor {
899            id: Uuid::nil(),
900            username: "erika".into(),
901            display_name: "Erika".into(),
902            role: Role::Editor,
903            skin: String::new(),
904            preferred_cad: String::new(),
905            projects: Vec::new(),
906            hidden_projects: Vec::new(),
907        }
908    }
909
910    #[test]
911    fn envelope_and_objects() {
912        let id = Uuid::new_v4();
913        let p = payload(
914            WebhookEvent::RevisionObsoleted,
915            id,
916            Some(&actor()),
917            json!({ "revision": { "id": 1 }, "event": "spoofed" }),
918        );
919        assert_eq!(
920            p["event"], "revision.obsoleted",
921            "objects cannot override the envelope"
922        );
923        assert_eq!(p["id"], json!(id));
924        assert_eq!(p["actor"], json!({ "username": "erika" }));
925        assert_eq!(p["revision"]["id"], 1);
926        let at = p["occurred_at"].as_str().unwrap();
927        assert!(chrono::DateTime::parse_from_rfc3339(at).is_ok() && at.ends_with('Z'));
928        assert_eq!(
929            payload(WebhookEvent::ChangeReleased, id, None, json!({}))["actor"]["username"],
930            "system"
931        );
932    }
933
934    #[test]
935    fn secrets_and_hints() {
936        let (secret, hint) = new_secret();
937        assert!(secret.starts_with(SECRET_PREFIX) && secret.len() == SECRET_PREFIX.len() + 64);
938        assert!(hint.starts_with(SECRET_PREFIX) && hint.ends_with(&secret[secret.len() - 4..]));
939        assert!(
940            hint.chars().count() < 20,
941            "the hint does not reveal the secret"
942        );
943        assert_ne!(new_secret().0, secret);
944    }
945
946    #[test]
947    fn input_validation() {
948        let policy = TargetPolicy::default();
949        let input = |url: &str, events: &[&str]| WebhookInput {
950            name: "ERP export".into(),
951            url: url.into(),
952            events: events.iter().map(|e| e.to_string()).collect(),
953            project_id: None,
954            active: true,
955        };
956        let v = validate(
957            &policy,
958            &input(
959                " https://erp.example.org/hook ",
960                &["change.released", "revision.released", "change.released"],
961            ),
962        )
963        .unwrap();
964        assert_eq!(v.url, "https://erp.example.org/hook");
965        assert_eq!(
966            v.events,
967            ["revision.released", "change.released"],
968            "canonical order, once"
969        );
970        assert!(validate(&policy, &input("https://erp.example.org", &[])).is_err());
971        assert!(validate(
972            &policy,
973            &input("https://erp.example.org", &["item.deleted"])
974        )
975        .is_err());
976        assert!(validate(
977            &policy,
978            &input("http://192.168.0.10/", &["change.released"])
979        )
980        .is_err());
981        assert!(validate(
982            &policy,
983            &input("ftp://x.example.org/", &["change.released"])
984        )
985        .is_err());
986        assert_eq!(
987            host_of("https://ci.example.org/x?token=abc"),
988            "ci.example.org"
989        );
990    }
991
992    #[test]
993    fn object_urls_are_relative_and_encoded() {
994        let now = Utc::now();
995        let rev = Revision {
996            id: Uuid::nil(),
997            item_id: Uuid::nil(),
998            item_number: "PRT-000001".into(),
999            item_type: "part".into(),
1000            unit: "each".into(),
1001            project_id: None,
1002            project_code: Some("ROVER".into()),
1003            seq: 1,
1004            code: "A".into(),
1005            state: "in_review".into(),
1006            name: "Bracket".into(),
1007            description: String::new(),
1008            material: String::new(),
1009            mass_kg: None,
1010            unit_cost: None,
1011            attributes: serde_json::json!({}),
1012            classification: "make".into(),
1013            checked_out_by: None,
1014            checked_out_by_name: None,
1015            checked_out_at: None,
1016            created_by: Uuid::nil(),
1017            created_by_name: "x".into(),
1018            created_at: now,
1019            updated_at: now,
1020            released_at: None,
1021        };
1022        let o = revision_object(&rev, "released");
1023        assert_eq!(o["state"], "released");
1024        assert_eq!(o["label"], "PRT-000001/A");
1025        assert_eq!(o["project"], "ROVER");
1026        assert_eq!(o["web_url"], format!("/revisions/{}", Uuid::nil()));
1027        assert_eq!(o["api_url"], "/api/v1/items/PRT-000001/revisions/A");
1028    }
1029}