pylon_plugin/builtin/
api_keys.rs1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use sha2::{Digest, Sha256};
5
6use crate::{Plugin, PluginError};
7use pylon_auth::AuthContext;
8
9#[derive(Debug, Clone)]
13pub struct ApiKey {
14 pub key_hash: String,
15 pub name: String,
16 pub user_id: String,
17 pub scopes: Vec<String>,
18 pub created_at: String,
19}
20
21#[derive(Debug, Clone)]
24pub struct CreatedApiKey {
25 pub raw_key: String,
27 pub name: String,
28 pub user_id: String,
29 pub scopes: Vec<String>,
30 pub created_at: String,
31}
32
33pub struct ApiKeysPlugin {
38 keys: Mutex<HashMap<String, ApiKey>>,
40}
41
42fn hex_encode(bytes: &[u8]) -> String {
43 bytes.iter().map(|b| format!("{:02x}", b)).collect()
44}
45
46fn hash_key(key: &str) -> String {
50 let mut hasher = Sha256::new();
51 hasher.update(key.as_bytes());
52 let result = hasher.finalize();
53 hex_encode(&result)
54}
55
56fn generate_key() -> String {
58 use rand::Rng;
59 let mut rng = rand::thread_rng();
60 let bytes: [u8; 24] = rng.gen();
61 format!("pylon_{}", hex_encode(&bytes))
62}
63
64impl ApiKeysPlugin {
65 pub fn new() -> Self {
66 Self {
67 keys: Mutex::new(HashMap::new()),
68 }
69 }
70
71 pub fn create_key(&self, name: &str, user_id: &str, scopes: Vec<String>) -> CreatedApiKey {
74 let raw_key = generate_key();
75 let key_hash = hash_key(&raw_key);
76
77 let api_key = ApiKey {
78 key_hash: key_hash.clone(),
79 name: name.to_string(),
80 user_id: user_id.to_string(),
81 scopes: scopes.clone(),
82 created_at: now(),
83 };
84 self.keys.lock().unwrap().insert(key_hash, api_key);
85
86 CreatedApiKey {
87 raw_key,
88 name: name.to_string(),
89 user_id: user_id.to_string(),
90 scopes,
91 created_at: now(),
92 }
93 }
94
95 pub fn resolve(&self, key: &str) -> Option<AuthContext> {
105 let h = hash_key(key);
106 let keys = self.keys.lock().unwrap();
107 keys.get(&h)
108 .map(|k| AuthContext::authenticated(k.user_id.clone()))
109 }
110
111 pub fn is_active(&self, key: &str) -> bool {
115 let h = hash_key(key);
116 self.keys.lock().unwrap().contains_key(&h)
117 }
118
119 pub fn has_scope(&self, key: &str, scope: &str) -> bool {
121 let h = hash_key(key);
122 let keys = self.keys.lock().unwrap();
123 keys.get(&h)
124 .map(|k| k.scopes.is_empty() || k.scopes.iter().any(|s| s == scope || s == "*"))
125 .unwrap_or(false)
126 }
127
128 pub fn revoke(&self, key: &str) -> bool {
131 let h = hash_key(key);
132 self.keys.lock().unwrap().remove(&h).is_some()
133 }
134
135 pub fn list_keys(&self, user_id: &str) -> Vec<ApiKey> {
139 self.keys
140 .lock()
141 .unwrap()
142 .values()
143 .filter(|k| k.user_id == user_id)
144 .cloned()
145 .collect()
146 }
147}
148
149impl Plugin for ApiKeysPlugin {
150 fn name(&self) -> &str {
151 "api-keys"
152 }
153
154 fn on_request(
155 &self,
156 _method: &str,
157 _path: &str,
158 _auth: &AuthContext,
159 ) -> Result<(), PluginError> {
160 Ok(())
163 }
164}
165
166fn now() -> String {
167 use std::time::{SystemTime, UNIX_EPOCH};
168 format!(
169 "{}Z",
170 SystemTime::now()
171 .duration_since(UNIX_EPOCH)
172 .unwrap_or_default()
173 .as_secs()
174 )
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn create_and_resolve() {
183 let plugin = ApiKeysPlugin::new();
184 let created = plugin.create_key("test-key", "user-1", vec!["read".into(), "write".into()]);
185 assert!(created.raw_key.starts_with("pylon_"));
186
187 let ctx = plugin.resolve(&created.raw_key).unwrap();
188 assert_eq!(ctx.user_id, Some("user-1".into()));
189 }
190
191 #[test]
192 fn raw_key_not_stored() {
193 let plugin = ApiKeysPlugin::new();
194 let created = plugin.create_key("test-key", "user-1", vec![]);
195 let keys = plugin.keys.lock().unwrap();
196 assert!(!keys.contains_key(&created.raw_key));
198 let stored = keys.values().next().unwrap();
200 assert_ne!(stored.key_hash, created.raw_key);
201 assert_eq!(stored.key_hash, hash_key(&created.raw_key));
202 }
203
204 #[test]
205 fn resolve_invalid_key() {
206 let plugin = ApiKeysPlugin::new();
207 assert!(plugin.resolve("invalid").is_none());
208 }
209
210 #[test]
211 fn scope_checking() {
212 let plugin = ApiKeysPlugin::new();
213 let created = plugin.create_key("test", "user-1", vec!["read".into()]);
214
215 assert!(plugin.has_scope(&created.raw_key, "read"));
216 assert!(!plugin.has_scope(&created.raw_key, "write"));
217 }
218
219 #[test]
220 fn wildcard_scope() {
221 let plugin = ApiKeysPlugin::new();
222 let created = plugin.create_key("admin", "user-1", vec!["*".into()]);
223
224 assert!(plugin.has_scope(&created.raw_key, "read"));
225 assert!(plugin.has_scope(&created.raw_key, "write"));
226 assert!(plugin.has_scope(&created.raw_key, "anything"));
227 }
228
229 #[test]
230 fn empty_scopes_allows_all() {
231 let plugin = ApiKeysPlugin::new();
232 let created = plugin.create_key("full-access", "user-1", vec![]);
233
234 assert!(plugin.has_scope(&created.raw_key, "read"));
235 assert!(plugin.has_scope(&created.raw_key, "write"));
236 }
237
238 #[test]
239 fn revoke_key() {
240 let plugin = ApiKeysPlugin::new();
241 let created = plugin.create_key("test", "user-1", vec![]);
242
243 assert!(plugin.revoke(&created.raw_key));
244 assert!(plugin.resolve(&created.raw_key).is_none());
245 assert!(!plugin.revoke(&created.raw_key)); }
247
248 #[test]
249 fn is_active_tracks_revocation() {
250 let plugin = ApiKeysPlugin::new();
251 let created = plugin.create_key("test", "user-1", vec![]);
252 assert!(plugin.is_active(&created.raw_key));
253 plugin.revoke(&created.raw_key);
254 assert!(!plugin.is_active(&created.raw_key));
255 }
256
257 #[test]
258 fn list_keys_by_user() {
259 let plugin = ApiKeysPlugin::new();
260 plugin.create_key("key1", "user-1", vec![]);
261 plugin.create_key("key2", "user-1", vec![]);
262 plugin.create_key("key3", "user-2", vec![]);
263
264 let keys = plugin.list_keys("user-1");
265 assert_eq!(keys.len(), 2);
266
267 let keys = plugin.list_keys("user-2");
268 assert_eq!(keys.len(), 1);
269 }
270
271 #[test]
272 fn generated_keys_are_unique() {
273 let k1 = generate_key();
274 let k2 = generate_key();
275 assert_ne!(k1, k2);
276 assert!(k1.starts_with("pylon_"));
277 assert_eq!(k1.len(), 54);
279 }
280
281 #[test]
282 fn hash_key_is_deterministic() {
283 let h1 = hash_key("test_key_123");
284 let h2 = hash_key("test_key_123");
285 assert_eq!(h1, h2);
286 }
287
288 #[test]
289 fn hash_key_differs_for_different_inputs() {
290 let h1 = hash_key("key_a");
291 let h2 = hash_key("key_b");
292 assert_ne!(h1, h2);
293 }
294
295 #[test]
296 fn hash_key_is_sha256_length() {
297 let h = hash_key("test");
298 assert_eq!(h.len(), 64);
300 }
301}