Skip to main content

ironflow_store/memory/
api_key_store.rs

1//! [`ApiKeyStore`] trait implementation for [`InMemoryStore`].
2
3use chrono::Utc;
4use uuid::Uuid;
5
6use crate::api_key_store::ApiKeyStore;
7use crate::entities::{ApiKey, ApiKeyUpdate, NewApiKey};
8use crate::error::StoreError;
9use crate::store::StoreFuture;
10
11use super::InMemoryStore;
12
13impl ApiKeyStore for InMemoryStore {
14    fn create_api_key(&self, req: NewApiKey) -> StoreFuture<'_, ApiKey> {
15        Box::pin(async move {
16            let mut state = self.state.write().await;
17            let now = Utc::now();
18            let id = Uuid::now_v7();
19            let key = ApiKey {
20                id,
21                user_id: req.user_id,
22                name: req.name,
23                key_hash: req.key_hash,
24                key_prefix: req.key_prefix,
25                scopes: req.scopes,
26                is_active: true,
27                expires_at: req.expires_at,
28                last_used_at: None,
29                created_at: now,
30                updated_at: now,
31            };
32            state.api_keys.insert(id, key.clone());
33            Ok(key)
34        })
35    }
36
37    fn find_api_key_by_prefix(&self, prefix: &str) -> StoreFuture<'_, Option<ApiKey>> {
38        let prefix = prefix.to_string();
39        Box::pin(async move {
40            let state = self.state.read().await;
41            Ok(state
42                .api_keys
43                .values()
44                .find(|k| k.key_prefix == prefix && k.is_active)
45                .cloned())
46        })
47    }
48
49    fn find_api_key_by_id(&self, id: Uuid) -> StoreFuture<'_, Option<ApiKey>> {
50        Box::pin(async move {
51            let state = self.state.read().await;
52            Ok(state.api_keys.get(&id).cloned())
53        })
54    }
55
56    fn list_api_keys_by_user(&self, user_id: Uuid) -> StoreFuture<'_, Vec<ApiKey>> {
57        Box::pin(async move {
58            let state = self.state.read().await;
59            let keys: Vec<ApiKey> = state
60                .api_keys
61                .values()
62                .filter(|k| k.user_id == user_id)
63                .cloned()
64                .collect();
65            Ok(keys)
66        })
67    }
68
69    fn update_api_key(&self, id: Uuid, update: ApiKeyUpdate) -> StoreFuture<'_, ()> {
70        Box::pin(async move {
71            let mut state = self.state.write().await;
72            let key = state
73                .api_keys
74                .get_mut(&id)
75                .ok_or(StoreError::Database(format!("API key {id} not found")))?;
76            if let Some(name) = update.name {
77                key.name = name;
78            }
79            if let Some(scopes) = update.scopes {
80                key.scopes = scopes;
81            }
82            if let Some(is_active) = update.is_active {
83                key.is_active = is_active;
84            }
85            if let Some(expires_at) = update.expires_at {
86                key.expires_at = expires_at;
87            }
88            key.updated_at = Utc::now();
89            Ok(())
90        })
91    }
92
93    fn touch_api_key(&self, id: Uuid) -> StoreFuture<'_, ()> {
94        Box::pin(async move {
95            let mut state = self.state.write().await;
96            if let Some(key) = state.api_keys.get_mut(&id) {
97                key.last_used_at = Some(Utc::now());
98            }
99            Ok(())
100        })
101    }
102
103    fn delete_api_key(&self, id: Uuid) -> StoreFuture<'_, ()> {
104        Box::pin(async move {
105            let mut state = self.state.write().await;
106            state.api_keys.remove(&id);
107            Ok(())
108        })
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use crate::entities::ApiKeyScope;
115
116    use super::*;
117
118    #[tokio::test]
119    async fn create_api_key_returns_active_key() {
120        let store = InMemoryStore::new();
121        let user_id = Uuid::now_v7();
122
123        let key = store
124            .create_api_key(NewApiKey {
125                user_id,
126                name: "production".to_string(),
127                key_hash: "bcrypt_hash".to_string(),
128                key_prefix: "sk_prod_abc123".to_string(),
129                scopes: vec![ApiKeyScope::WorkflowsRead],
130                expires_at: None,
131            })
132            .await
133            .unwrap();
134
135        assert_eq!(key.user_id, user_id);
136        assert_eq!(key.name, "production");
137        assert!(key.is_active);
138        assert!(key.last_used_at.is_none());
139    }
140
141    #[tokio::test]
142    async fn find_api_key_by_prefix_existing() {
143        let store = InMemoryStore::new();
144        let user_id = Uuid::now_v7();
145
146        let created = store
147            .create_api_key(NewApiKey {
148                user_id,
149                name: "test".to_string(),
150                key_hash: "hash".to_string(),
151                key_prefix: "sk_test_xyz".to_string(),
152                scopes: vec![],
153                expires_at: None,
154            })
155            .await
156            .unwrap();
157
158        let found = store
159            .find_api_key_by_prefix("sk_test_xyz")
160            .await
161            .unwrap()
162            .expect("key should exist");
163
164        assert_eq!(found.id, created.id);
165    }
166
167    #[tokio::test]
168    async fn find_api_key_by_prefix_missing_returns_none() {
169        let store = InMemoryStore::new();
170        let found = store
171            .find_api_key_by_prefix("sk_nonexistent")
172            .await
173            .unwrap();
174
175        assert!(found.is_none());
176    }
177
178    #[tokio::test]
179    async fn find_api_key_by_prefix_inactive_returns_none() {
180        let store = InMemoryStore::new();
181        let user_id = Uuid::now_v7();
182
183        let created = store
184            .create_api_key(NewApiKey {
185                user_id,
186                name: "test".to_string(),
187                key_hash: "hash".to_string(),
188                key_prefix: "sk_inactive".to_string(),
189                scopes: vec![],
190                expires_at: None,
191            })
192            .await
193            .unwrap();
194
195        // Deactivate the key
196        store
197            .update_api_key(
198                created.id,
199                ApiKeyUpdate {
200                    name: None,
201                    scopes: None,
202                    is_active: Some(false),
203                    expires_at: None,
204                },
205            )
206            .await
207            .unwrap();
208
209        let found = store.find_api_key_by_prefix("sk_inactive").await.unwrap();
210
211        assert!(found.is_none());
212    }
213
214    #[tokio::test]
215    async fn find_api_key_by_id_existing() {
216        let store = InMemoryStore::new();
217        let user_id = Uuid::now_v7();
218
219        let created = store
220            .create_api_key(NewApiKey {
221                user_id,
222                name: "test".to_string(),
223                key_hash: "hash".to_string(),
224                key_prefix: "sk_test".to_string(),
225                scopes: vec![],
226                expires_at: None,
227            })
228            .await
229            .unwrap();
230
231        let found = store
232            .find_api_key_by_id(created.id)
233            .await
234            .unwrap()
235            .expect("key should exist");
236
237        assert_eq!(found.id, created.id);
238    }
239
240    #[tokio::test]
241    async fn find_api_key_by_id_missing_returns_none() {
242        let store = InMemoryStore::new();
243        let found = store.find_api_key_by_id(Uuid::now_v7()).await.unwrap();
244
245        assert!(found.is_none());
246    }
247
248    #[tokio::test]
249    async fn list_api_keys_by_user_returns_only_user_keys() {
250        let store = InMemoryStore::new();
251        let user1 = Uuid::now_v7();
252        let user2 = Uuid::now_v7();
253
254        store
255            .create_api_key(NewApiKey {
256                user_id: user1,
257                name: "key1".to_string(),
258                key_hash: "hash1".to_string(),
259                key_prefix: "sk_1".to_string(),
260                scopes: vec![],
261                expires_at: None,
262            })
263            .await
264            .unwrap();
265
266        store
267            .create_api_key(NewApiKey {
268                user_id: user1,
269                name: "key2".to_string(),
270                key_hash: "hash2".to_string(),
271                key_prefix: "sk_2".to_string(),
272                scopes: vec![],
273                expires_at: None,
274            })
275            .await
276            .unwrap();
277
278        store
279            .create_api_key(NewApiKey {
280                user_id: user2,
281                name: "key3".to_string(),
282                key_hash: "hash3".to_string(),
283                key_prefix: "sk_3".to_string(),
284                scopes: vec![],
285                expires_at: None,
286            })
287            .await
288            .unwrap();
289
290        let user1_keys = store.list_api_keys_by_user(user1).await.unwrap();
291        assert_eq!(user1_keys.len(), 2);
292        assert!(user1_keys.iter().all(|k| k.user_id == user1));
293    }
294
295    #[tokio::test]
296    async fn update_api_key_name() {
297        let store = InMemoryStore::new();
298        let user_id = Uuid::now_v7();
299
300        let created = store
301            .create_api_key(NewApiKey {
302                user_id,
303                name: "old-name".to_string(),
304                key_hash: "hash".to_string(),
305                key_prefix: "sk_test".to_string(),
306                scopes: vec![],
307                expires_at: None,
308            })
309            .await
310            .unwrap();
311
312        store
313            .update_api_key(
314                created.id,
315                ApiKeyUpdate {
316                    name: Some("new-name".to_string()),
317                    scopes: None,
318                    is_active: None,
319                    expires_at: None,
320                },
321            )
322            .await
323            .unwrap();
324
325        let updated = store.find_api_key_by_id(created.id).await.unwrap().unwrap();
326
327        assert_eq!(updated.name, "new-name");
328    }
329
330    #[tokio::test]
331    async fn update_api_key_scopes() {
332        let store = InMemoryStore::new();
333        let user_id = Uuid::now_v7();
334
335        let created = store
336            .create_api_key(NewApiKey {
337                user_id,
338                name: "test".to_string(),
339                key_hash: "hash".to_string(),
340                key_prefix: "sk_test".to_string(),
341                scopes: vec![ApiKeyScope::WorkflowsRead],
342                expires_at: None,
343            })
344            .await
345            .unwrap();
346
347        let new_scopes = vec![ApiKeyScope::WorkflowsRead, ApiKeyScope::RunsWrite];
348
349        store
350            .update_api_key(
351                created.id,
352                ApiKeyUpdate {
353                    name: None,
354                    scopes: Some(new_scopes.clone()),
355                    is_active: None,
356                    expires_at: None,
357                },
358            )
359            .await
360            .unwrap();
361
362        let updated = store.find_api_key_by_id(created.id).await.unwrap().unwrap();
363
364        assert_eq!(updated.scopes, new_scopes);
365    }
366
367    #[tokio::test]
368    async fn touch_api_key_updates_last_used() {
369        let store = InMemoryStore::new();
370        let user_id = Uuid::now_v7();
371
372        let created = store
373            .create_api_key(NewApiKey {
374                user_id,
375                name: "test".to_string(),
376                key_hash: "hash".to_string(),
377                key_prefix: "sk_test".to_string(),
378                scopes: vec![],
379                expires_at: None,
380            })
381            .await
382            .unwrap();
383
384        assert!(created.last_used_at.is_none());
385
386        store.touch_api_key(created.id).await.unwrap();
387
388        let touched = store.find_api_key_by_id(created.id).await.unwrap().unwrap();
389
390        assert!(touched.last_used_at.is_some());
391    }
392
393    #[tokio::test]
394    async fn delete_api_key() {
395        let store = InMemoryStore::new();
396        let user_id = Uuid::now_v7();
397
398        let created = store
399            .create_api_key(NewApiKey {
400                user_id,
401                name: "test".to_string(),
402                key_hash: "hash".to_string(),
403                key_prefix: "sk_test".to_string(),
404                scopes: vec![],
405                expires_at: None,
406            })
407            .await
408            .unwrap();
409
410        store.delete_api_key(created.id).await.unwrap();
411
412        let found = store.find_api_key_by_id(created.id).await.unwrap();
413
414        assert!(found.is_none());
415    }
416
417    #[tokio::test]
418    async fn delete_nonexistent_api_key_is_idempotent() {
419        let store = InMemoryStore::new();
420        let result = store.delete_api_key(Uuid::now_v7()).await;
421        assert!(result.is_ok());
422    }
423}