1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct WebhookTarget {
20 pub id: String,
22 pub url: String,
24 pub secret: Option<String>,
26 pub events: Vec<String>,
28 pub enabled: bool,
30}
31
32#[derive(Debug, Clone, Serialize)]
34pub struct WebhookPayload {
35 pub event: String,
36 pub timestamp: String,
37 pub details: HashMap<String, String>,
38}
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42pub struct WebhookConfig {
43 pub targets: Vec<WebhookTarget>,
44}
45
46pub 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 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 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 pub fn list(&self) -> &[WebhookTarget] {
101 &self.config.targets
102 }
103
104 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 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 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
171impl 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#[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 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); let targets = store.targets_for_event("model.deleted");
347 assert_eq!(targets.len(), 1); }
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}