freta/models/webhooks/
mod.rs

1// Copyright (C) Microsoft Corporation. All rights reserved.
2
3/// REST API models for Webhooks
4pub mod service;
5
6use crate::{ImageId, OwnerId, Secret};
7use clap::ValueEnum;
8use getrandom::getrandom;
9use hmac::{Hmac, Mac};
10use serde::{Deserialize, Serialize};
11use sha2::Sha512;
12use std::{
13    collections::BTreeSet,
14    fmt::{Display, Error as FmtError, Formatter},
15    str::FromStr,
16    time::SystemTime,
17};
18use time::OffsetDateTime;
19use url::Url;
20use uuid::Uuid;
21
22/// HTTP Header used to validate HMAC-SHA512 signatures of the webhook payloads
23pub const DIGEST_HEADER: &str = "x-freta-digest";
24
25/// Unique identifier for a `Webhook`
26#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq, Hash)]
27pub struct WebhookId(Uuid);
28
29impl WebhookId {
30    /// Generate a new `WebhookId`
31    #[must_use]
32    pub fn new() -> Self {
33        Self(Uuid::new_v4())
34    }
35}
36
37impl Default for WebhookId {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl Display for WebhookId {
44    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> {
45        write!(f, "{}", self.0)
46    }
47}
48
49impl FromStr for WebhookId {
50    type Err = uuid::Error;
51
52    fn from_str(uuid_str: &str) -> Result<Self, Self::Err> {
53        Uuid::parse_str(uuid_str).map(Self)
54    }
55}
56
57/// Unique identifier for a `WebhookEvent` entry
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq)]
60pub struct WebhookEventId(Uuid);
61
62impl WebhookEventId {
63    /// Generate a new `WebhookEventId`
64    #[must_use]
65    pub fn new() -> Self {
66        Self(new_uuid_v7())
67    }
68}
69
70impl Default for WebhookEventId {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl Display for WebhookEventId {
77    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> {
78        write!(f, "{}", self.0)
79    }
80}
81
82impl FromStr for WebhookEventId {
83    type Err = uuid::Error;
84
85    fn from_str(uuid_str: &str) -> Result<Self, Self::Err> {
86        Uuid::parse_str(uuid_str).map(Self)
87    }
88}
89
90/// Webhook Event Types
91#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92#[derive(Debug, Serialize, Deserialize, Clone, ValueEnum, Ord, Eq, PartialEq, PartialOrd)]
93#[serde(rename_all = "snake_case")]
94#[value(rename_all = "snake_case")]
95pub enum WebhookEventType {
96    #[clap(skip)]
97    /// Ping event, used to validate the webhook functionality
98    Ping,
99    /// an Image was created
100    ImageCreated,
101    /// an Image was deleted
102    ImageDeleted,
103    /// an Image was successfully analyzed
104    ImageAnalysisCompleted,
105    /// an Image failed to be analyzed
106    ImageAnalysisFailed,
107    /// an Image State was updated
108    ImageStateUpdated,
109}
110
111/// Freta Webhook Event
112///
113/// This struct defines the structure of a webhook event sent to user's
114/// configured HTTP endpoint via HTTP POST.
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116#[derive(Debug, Serialize, Deserialize, Clone)]
117pub struct WebhookEvent {
118    /// Unique identifier for the event
119    pub event_id: WebhookEventId,
120
121    /// Type of the event
122    pub event_type: WebhookEventType,
123
124    /// Timestamp of when the event occurred
125    #[serde(with = "time::serde::rfc3339")]
126    #[cfg_attr(feature = "schema", schemars(with = "String"))]
127    pub timestamp: OffsetDateTime,
128
129    /// The image that triggered the event, if applicable
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub image: Option<ImageId>,
132}
133
134impl WebhookEvent {
135    /// Create a new webhook event
136    #[must_use]
137    pub fn new(
138        event_type: WebhookEventType,
139        timestamp: OffsetDateTime,
140        image: Option<ImageId>,
141    ) -> Self {
142        Self {
143            event_id: WebhookEventId::new(),
144            event_type,
145            timestamp,
146            image,
147        }
148    }
149}
150
151/// Freta errors
152#[derive(thiserror::Error, Debug)]
153pub enum HmacError {
154    /// Unable to create an HMAC from the provided token
155    #[error("invalid hmac token")]
156    InvalidHmacToken,
157
158    /// HMAC structure serialization failures
159    #[error("serialization error")]
160    Serialization(#[from] serde_json::Error),
161}
162
163impl WebhookEvent {
164    /// Generate a HMAC for the event using the provided token
165    ///
166    /// # Errors
167    /// This could fail if the provided token is invalid or if the event cannot be serialized
168    pub fn hmac_sha512(&self, hmac_token: &Secret) -> Result<String, HmacError> {
169        let event_as_bytes = serde_json::to_string(&self)?.as_bytes().to_vec();
170        hmac_sha512(&event_as_bytes, hmac_token)
171    }
172}
173
174/// Generate a HMAC SHA512 for a slice of bytes using the provided token
175///
176/// # Errors
177/// This could fail if the provided token is invalid
178pub fn hmac_sha512(bytes: &[u8], hmac_token: &Secret) -> Result<String, HmacError> {
179    let mut mac = Hmac::<Sha512>::new_from_slice(hmac_token.get_secret().as_bytes())
180        .map_err(|_| HmacError::InvalidHmacToken)?;
181    mac.update(bytes);
182    let result = mac.finalize().into_bytes();
183    let hmac_as_string = result
184        .iter()
185        .map(|b| format!("{b:02x}"))
186        .collect::<String>();
187    Ok(hmac_as_string)
188}
189
190/// Webhook Event State
191///
192/// This enum defines the current state of sending the event to the configured
193/// webhook.
194#[derive(Debug, Serialize, Deserialize, Clone)]
195pub enum WebhookEventState {
196    /// The event has not been sent to the webhook
197    Pending,
198    /// The event has been sent to the webhook
199    Success,
200    /// The event has been sent to the webhook, but the webhook responded with
201    /// an error
202    Failure,
203}
204
205/// Webhook configuration
206#[derive(Debug, Serialize, Deserialize, Clone)]
207pub struct Webhook {
208    /// Timestamp of the last time the webhook was updated
209    #[serde(
210        rename(deserialize = "Timestamp"),
211        alias = "last_updated",
212        skip_serializing_if = "Option::is_none",
213        default,
214        with = "time::serde::rfc3339::option"
215    )]
216    pub last_updated: Option<OffsetDateTime>,
217
218    /// Unique identifier of the owner of the image
219    #[serde(rename(deserialize = "PartitionKey"), alias = "owner_id")]
220    pub owner_id: OwnerId,
221
222    /// Unique identifier of the webhook
223    #[serde(rename(deserialize = "RowKey"), alias = "webhook_id")]
224    pub webhook_id: WebhookId,
225
226    /// The webhook url
227    pub url: Url,
228
229    /// The webhook events that should be included in the
230    pub event_types: BTreeSet<WebhookEventType>,
231
232    /// If provided, the value will be used to generate an HMAC-SHA512 of the
233    /// payload, which will be added to the HTTP Headers as `X-Freta-Digest`.
234    pub hmac_token: Option<Secret>,
235}
236
237impl Webhook {
238    /// Create a new Webhook
239    #[must_use]
240    pub fn new(
241        owner_id: OwnerId,
242        url: Url,
243        event_types: BTreeSet<WebhookEventType>,
244        hmac_token: Option<Secret>,
245    ) -> Self {
246        Self {
247            last_updated: None,
248            owner_id,
249            webhook_id: WebhookId::new(),
250            url,
251            event_types,
252            hmac_token,
253        }
254    }
255}
256
257/// A log of recent webhook events that have fired
258#[derive(Debug, Serialize, Deserialize, Clone)]
259pub struct WebhookLog {
260    /// Timestamp of the last time the webhook was updated
261    #[serde(
262        rename(deserialize = "Timestamp"),
263        alias = "last_updated",
264        skip_serializing_if = "Option::is_none",
265        default,
266        with = "time::serde::rfc3339::option"
267    )]
268    pub last_updated: Option<OffsetDateTime>,
269
270    /// Unique identifier of the webhook
271    #[serde(rename(deserialize = "PartitionKey"), alias = "webhook_id")]
272    pub webhook_id: WebhookId,
273
274    /// Unique identifier of the event
275    #[serde(rename(deserialize = "RowKey"), alias = "event_id")]
276    pub event_id: WebhookEventId,
277
278    /// The webhook event
279    pub event: WebhookEvent,
280
281    /// The webhook event state
282    pub state: WebhookEventState,
283
284    /// The webhook event response
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub error: Option<String>,
287}
288
289impl WebhookLog {
290    /// Create a new event for a given webhook
291    #[must_use]
292    pub fn new(
293        webhook_id: WebhookId,
294        event_type: WebhookEventType,
295        timestamp: OffsetDateTime,
296        image_id: Option<ImageId>,
297    ) -> Self {
298        let event = WebhookEvent::new(event_type, timestamp, image_id);
299        Self {
300            last_updated: None,
301            webhook_id,
302            event_id: event.event_id,
303            event,
304            state: WebhookEventState::Pending,
305            error: None,
306        }
307    }
308}
309
310/// Generate a UUID following the DRAFT `UUIDv7` specification
311///
312/// Ref: <https://datatracker.ietf.org/doc/html/draft-peabody-dispatch-new-uuid-format#name-uuid-version-7>.
313///
314/// Using `UUIDv7` provides for us a unique identifier that is lexicographically
315/// sortable by time.
316///
317/// Of note, the current `UUIDv7` draft discusses monotonicity as it relates to
318/// time-based sortable values.  This implementation does not handle clock
319/// rolebacks or leap seconds.  In practice, this implementation of
320/// lexicographical sorting should be considered a best effort.
321///
322/// # Panics
323///
324/// This function will panic if the system is unable to return the current time
325/// relative to UNIX epoch or if it is unable to get 10 random bytes.
326///
327/// Both of these cases model the `uuid` crate's implementation.
328#[allow(clippy::expect_used, clippy::cast_possible_truncation)]
329fn new_uuid_v7() -> Uuid {
330    let now = SystemTime::UNIX_EPOCH
331        .elapsed()
332        .expect("getting elapsed time since UNIX_EPOCH should not fail")
333        .as_millis() as u64;
334    let mut random_bytes = [0_u8; 10];
335    getrandom(&mut random_bytes).expect("getting random value failed");
336    fmt_uuid_v7(now, random_bytes)
337}
338
339/// Format a timestamp and random bytes following the `UUIDv7` draft specification
340///
341/// The implementation is directly based off the rust crate `uuid`, which has the
342/// copyright & license as stated below the link to the original implementation.
343/// As the Freta crate is licensed MIT, this is compatible.  Once the `uuid`
344/// crate has a stable implementation of `UUIDv7` this should be removed and the
345/// `uuid` crate should be used directly instead.
346///
347/// Ref: <https://github.com/uuid-rs/uuid/blob/60ca9af4c18e9a5131ceb43f54af308ded4ae6c0/src/timestamp.rs#L236-L255>
348///
349/// ```doc
350/// The Uuid Project is copyright 2013-2014, The Rust Project Developers and
351/// copyright 2018, The Uuid Developers.
352///
353/// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
354/// http://www.apache.org/licenses/LICENSE-2.0> or the MIT License <LICENSE-MIT or
355/// http://opensource.org/licenses/MIT>, at your option. All files in the project
356/// carrying such notice may not be copied, modified, or distributed except
357/// according to those terms.
358/// ```
359const fn fmt_uuid_v7(millis: u64, random_bytes: [u8; 10]) -> Uuid {
360    // get the first 16 bits of the timestamp
361    let millis_low = (millis & 0xFFFF) as u16;
362    // get the next 32 bits of the timestamp
363    let millis_high = ((millis >> 16) & 0xFFFF_FFFF) as u32;
364
365    let random_and_version =
366        (random_bytes[0] as u16 | ((random_bytes[1] as u16) << 8) & 0x0FFF) | (0x7 << 12);
367
368    let mut d4 = [0; 8];
369
370    d4[0] = (random_bytes[2] & 0x3F) | 0x80;
371    d4[1] = random_bytes[3];
372    d4[2] = random_bytes[4];
373    d4[3] = random_bytes[5];
374    d4[4] = random_bytes[6];
375    d4[5] = random_bytes[7];
376    d4[6] = random_bytes[8];
377    d4[7] = random_bytes[9];
378
379    // Of note, `Uuid::from_fields` handles converting the integer values to the
380    // appropriate endianness.
381    Uuid::from_fields(millis_high, millis_low, random_and_version, &d4)
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use std::{thread::sleep, time::Duration};
388
389    type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
390
391    #[test]
392    fn test_uuid_v7_format() {
393        let examples = vec![
394            fmt_uuid_v7(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
395            fmt_uuid_v7(1_673_483_814 * 1000, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
396            fmt_uuid_v7(
397                1_673_483_814 * 1000,
398                [11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
399            ),
400            fmt_uuid_v7(
401                1_673_483_815 * 1000,
402                [11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
403            ),
404        ];
405
406        insta::assert_json_snapshot!(examples);
407    }
408
409    #[test]
410    /// test the lexicographical sorting of the `UUIDv7` implementation
411    ///
412    /// This test may fail if it happens to span across midnight after a day
413    /// which contains a leap second.
414    fn test_lexicographical_sorting() {
415        let two_millis = Duration::from_millis(2);
416        let mut uuids = vec![];
417
418        for _ in 0..100 {
419            uuids.push(new_uuid_v7().to_string());
420            // sleep 2 millis between generation, as the resolution that `UUIDv7` ensures
421            // lexicographical sorting is 1 millis.  sleeping 2 millis ensures the clock used by
422            // `new_uuid_v7` has at least one tick between calls.
423            sleep(two_millis);
424        }
425
426        let mut sorted = uuids.clone();
427        sorted.sort();
428
429        assert_eq!(
430            uuids, sorted,
431            "UUIDv7 should be lexicographically sorted during generation"
432        );
433    }
434
435    #[test]
436    fn test_hmac() -> Result<()> {
437        let event = WebhookEvent {
438            event_id: WebhookEventId(Uuid::from_u128(1)),
439            event_type: WebhookEventType::ImageCreated,
440            timestamp: OffsetDateTime::UNIX_EPOCH,
441            image: Some(Uuid::from_u128(0).into()),
442        };
443
444        let hmac = event.hmac_sha512(&Secret::new("testing"))?;
445        insta::assert_json_snapshot!(hmac);
446        let event_as_string = serde_json::to_string(&event)?;
447        insta::assert_json_snapshot!(event_as_string);
448
449        Ok(())
450    }
451}