Skip to main content

ai_model_vault/
webhooks.rs

1//! Webhook / notification system — fire HTTP callbacks on vault events.
2//!
3//! Implements the `EventSubscriber` trait so it slots into the existing
4//! `EventBus`.  Webhook targets are persisted in `webhooks.json`.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Result, VaultError};
13use crate::traits::{EventSubscriber, VaultEvent};
14
15// ── Types ────────────────────────────────────────────────────────────────────
16
17/// A registered webhook target.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct WebhookTarget {
20    /// Unique identifier.
21    pub id: String,
22    /// Destination URL (HTTPS required in production).
23    pub url: String,
24    /// Optional shared secret for HMAC-SHA256 payload signing.
25    pub secret: Option<String>,
26    /// Events this hook should fire for (empty = all).
27    pub events: Vec<String>,
28    /// Whether this webhook is currently enabled.
29    pub enabled: bool,
30}
31
32/// Webhook delivery payload.
33#[derive(Debug, Clone, Serialize)]
34pub struct WebhookPayload {
35    pub event: String,
36    pub timestamp: String,
37    pub details: HashMap<String, String>,
38}
39
40/// Persisted webhook configuration.
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42pub struct WebhookConfig {
43    pub targets: Vec<WebhookTarget>,
44}
45
46// ── Store ────────────────────────────────────────────────────────────────────
47
48/// Manages webhook registrations alongside a vault.
49pub struct WebhookStore {
50    path: PathBuf,
51    config: WebhookConfig,
52}
53
54impl WebhookStore {
55    const FILE_NAME: &'static str = "webhooks.json";
56
57    pub fn new(vault_path: &Path) -> Result<Self> {
58        let path = vault_path.join(Self::FILE_NAME);
59        let config = if path.exists() {
60            let data = fs::read_to_string(&path)?;
61            serde_json::from_str(&data).unwrap_or_default()
62        } else {
63            WebhookConfig::default()
64        };
65        Ok(Self { path, config })
66    }
67
68    fn save(&self) -> Result<()> {
69        let json = serde_json::to_string_pretty(&self.config)?;
70        fs::write(&self.path, json)?;
71        crate::permissions::restrict_file(&self.path)?;
72        Ok(())
73    }
74
75    /// Register a new webhook target.
76    pub fn add(&mut self, target: WebhookTarget) -> Result<()> {
77        if self.config.targets.iter().any(|t| t.id == target.id) {
78            return Err(VaultError::InvalidInput(format!(
79                "Webhook '{}' already exists",
80                target.id
81            )));
82        }
83        self.config.targets.push(target);
84        self.save()
85    }
86
87    /// Remove a webhook by ID.
88    pub fn remove(&mut self, id: &str) -> Result<bool> {
89        let before = self.config.targets.len();
90        self.config.targets.retain(|t| t.id != id);
91        if self.config.targets.len() < before {
92            self.save()?;
93            Ok(true)
94        } else {
95            Ok(false)
96        }
97    }
98
99    /// List all registered webhooks.
100    pub fn list(&self) -> &[WebhookTarget] {
101        &self.config.targets
102    }
103
104    /// Get targets that should fire for a given event name.
105    pub fn targets_for_event(&self, event: &str) -> Vec<&WebhookTarget> {
106        self.config
107            .targets
108            .iter()
109            .filter(|t| t.enabled && (t.events.is_empty() || t.events.iter().any(|e| e == event)))
110            .collect()
111    }
112
113    /// Fire a payload to matching targets (blocking HTTP POST).
114    ///
115    /// Delivery failures are logged but do not propagate errors to callers.
116    pub fn fire(&self, event: &str, details: HashMap<String, String>) {
117        let payload = WebhookPayload {
118            event: event.to_string(),
119            timestamp: chrono::Utc::now().to_rfc3339(),
120            details,
121        };
122
123        let body = match serde_json::to_string(&payload) {
124            Ok(b) => b,
125            Err(e) => {
126                tracing::warn!("webhook: failed to serialise payload: {e}");
127                return;
128            }
129        };
130
131        let client = reqwest::blocking::Client::builder()
132            .timeout(std::time::Duration::from_secs(10))
133            .build();
134
135        let client = match client {
136            Ok(c) => c,
137            Err(e) => {
138                tracing::warn!("webhook: failed to build HTTP client: {e}");
139                return;
140            }
141        };
142
143        for target in self.targets_for_event(event) {
144            let mut req = client
145                .post(&target.url)
146                .header("Content-Type", "application/json")
147                .header("X-AIM-Event", event);
148
149            // HMAC-SHA256 signature
150            if let Some(ref secret) = target.secret {
151                use sha2::Digest;
152                let mut mac = sha2::Sha256::new();
153                mac.update(secret.as_bytes());
154                mac.update(body.as_bytes());
155                let sig = hex::encode(mac.finalize());
156                req = req.header("X-AIM-Signature", sig);
157            }
158
159            match req.body(body.clone()).send() {
160                Ok(resp) => {
161                    tracing::debug!("webhook {}: status {}", target.id, resp.status());
162                }
163                Err(e) => {
164                    tracing::warn!("webhook {}: delivery failed: {e}", target.id);
165                }
166            }
167        }
168    }
169}
170
171// ── EventSubscriber integration ──────────────────────────────────────────────
172
173impl EventSubscriber for WebhookStore {
174    fn on_event(&self, event: &VaultEvent) -> Result<()> {
175        let (name, details) = event_to_webhook(event);
176        self.fire(&name, details);
177        Ok(())
178    }
179
180    fn name(&self) -> &str {
181        "WebhookStore"
182    }
183}
184
185fn event_to_webhook(event: &VaultEvent) -> (String, HashMap<String, String>) {
186    let mut details = HashMap::new();
187    let name = match event {
188        VaultEvent::VaultCreated { vault, .. } => {
189            details.insert("vault".into(), vault.clone());
190            "vault.created"
191        }
192        VaultEvent::VaultUnlocked { vault, .. } => {
193            details.insert("vault".into(), vault.clone());
194            "vault.unlocked"
195        }
196        VaultEvent::VaultLocked { vault, .. } => {
197            details.insert("vault".into(), vault.clone());
198            "vault.locked"
199        }
200        VaultEvent::ModelStored {
201            vault,
202            model,
203            version,
204            format,
205            size,
206            checksum,
207            ..
208        } => {
209            details.insert("vault".into(), vault.clone());
210            details.insert("model".into(), model.clone());
211            details.insert("version".into(), version.to_string());
212            details.insert("format".into(), format.clone());
213            details.insert("size".into(), size.to_string());
214            details.insert("checksum".into(), checksum.clone());
215            "model.stored"
216        }
217        VaultEvent::ModelRetrieved {
218            vault,
219            model,
220            version,
221            ..
222        } => {
223            details.insert("vault".into(), vault.clone());
224            details.insert("model".into(), model.clone());
225            details.insert("version".into(), version.to_string());
226            "model.retrieved"
227        }
228        VaultEvent::ModelDeleted {
229            vault,
230            model,
231            version,
232            ..
233        } => {
234            details.insert("vault".into(), vault.clone());
235            details.insert("model".into(), model.clone());
236            details.insert("version".into(), version.to_string());
237            "model.deleted"
238        }
239        VaultEvent::PassphraseChanged {
240            vault,
241            files_reencrypted,
242            ..
243        } => {
244            details.insert("vault".into(), vault.clone());
245            details.insert("files_reencrypted".into(), files_reencrypted.to_string());
246            "vault.passphrase_changed"
247        }
248        VaultEvent::IntegrityFailed {
249            vault,
250            model,
251            version,
252            expected,
253            actual,
254            ..
255        } => {
256            details.insert("vault".into(), vault.clone());
257            details.insert("model".into(), model.clone());
258            details.insert("version".into(), version.to_string());
259            details.insert("expected".into(), expected.clone());
260            details.insert("actual".into(), actual.clone());
261            "integrity.failed"
262        }
263        VaultEvent::ComplianceChecked { vault, passed, .. } => {
264            details.insert("vault".into(), vault.clone());
265            details.insert("passed".into(), passed.to_string());
266            "compliance.checked"
267        }
268    };
269    (name.to_string(), details)
270}
271
272// ── Tests ────────────────────────────────────────────────────────────────────
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_webhook_store_crud() {
280        let dir = tempfile::tempdir().unwrap();
281        let mut store = WebhookStore::new(dir.path()).unwrap();
282
283        let target = WebhookTarget {
284            id: "test-1".into(),
285            url: "https://example.com/hook".into(),
286            secret: None,
287            events: vec!["model.stored".into()],
288            enabled: true,
289        };
290
291        store.add(target).unwrap();
292        assert_eq!(store.list().len(), 1);
293
294        // Duplicate should fail
295        let dup = WebhookTarget {
296            id: "test-1".into(),
297            url: "https://example.com/other".into(),
298            secret: None,
299            events: vec![],
300            enabled: true,
301        };
302        assert!(store.add(dup).is_err());
303
304        assert!(store.remove("test-1").unwrap());
305        assert_eq!(store.list().len(), 0);
306    }
307
308    #[test]
309    fn test_targets_for_event() {
310        let dir = tempfile::tempdir().unwrap();
311        let mut store = WebhookStore::new(dir.path()).unwrap();
312
313        store
314            .add(WebhookTarget {
315                id: "all".into(),
316                url: "https://example.com/all".into(),
317                secret: None,
318                events: vec![],
319                enabled: true,
320            })
321            .unwrap();
322
323        store
324            .add(WebhookTarget {
325                id: "store-only".into(),
326                url: "https://example.com/store".into(),
327                secret: None,
328                events: vec!["model.stored".into()],
329                enabled: true,
330            })
331            .unwrap();
332
333        store
334            .add(WebhookTarget {
335                id: "disabled".into(),
336                url: "https://example.com/dis".into(),
337                secret: None,
338                events: vec![],
339                enabled: false,
340            })
341            .unwrap();
342
343        let targets = store.targets_for_event("model.stored");
344        assert_eq!(targets.len(), 2); // "all" + "store-only"
345
346        let targets = store.targets_for_event("model.deleted");
347        assert_eq!(targets.len(), 1); // only "all"
348    }
349
350    #[test]
351    fn test_event_to_webhook_mapping() {
352        let event = VaultEvent::ModelStored {
353            vault: "test-vault".into(),
354            model: "llama".into(),
355            version: 3,
356            format: "gguf".into(),
357            size: 1024,
358            checksum: "abc123".into(),
359            timestamp: chrono::Utc::now(),
360        };
361        let (name, details) = event_to_webhook(&event);
362        assert_eq!(name, "model.stored");
363        assert_eq!(details.get("model").unwrap(), "llama");
364    }
365}