1use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27use std::collections::HashMap;
28use thiserror::Error;
29
30#[cfg(feature = "async")]
31use async_trait::async_trait;
32
33#[derive(Error, Debug)]
36pub enum ControlPlaneError {
37 #[error("Client not found for the provided API key")]
38 NotFound,
39
40 #[error("Client record is inactive (disabled)")]
41 Inactive,
42
43 #[error("Backend unreachable: {0}")]
44 Unreachable(String),
45
46 #[error("Malformed client record: {0}")]
47 MalformedRecord(String),
48
49 #[error("Internal error: {0}")]
50 Internal(String),
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ClientRecord {
61 pub client_id: String,
63
64 #[serde(default = "default_active")]
67 pub is_active: bool,
68
69 #[serde(default = "default_permissions")]
71 pub permissions: Vec<String>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub rate_limit_rps: Option<u32>,
76
77 #[serde(default)]
79 pub metadata: HashMap<String, String>,
80}
81
82fn default_active() -> bool {
83 true
84}
85
86fn default_permissions() -> Vec<String> {
87 vec![
88 "read".into(),
89 "write".into(),
90 "replay".into(),
91 "delete".into(),
92 ]
93}
94
95#[cfg(feature = "async")]
104#[async_trait]
105pub trait ControlPlane: Send + Sync {
106 async fn lookup_client(&self, api_key: &str) -> Result<ClientRecord, ControlPlaneError>;
114
115 async fn health_check(&self) -> bool {
117 true
118 }
119
120 fn backend_name(&self) -> &str;
122}
123
124pub fn hash_api_key(api_key: &str) -> String {
128 let mut hasher = Sha256::new();
129 hasher.update(api_key.as_bytes());
130 let result = hasher.finalize();
131 result.iter().map(|b| format!("{:02x}", b)).collect()
132}
133
134
135pub struct LocalControlPlane {
142 clients: Vec<LocalClient>,
143}
144
145#[derive(Clone, Debug)]
147pub struct LocalClient {
148 pub client_id: String,
149 pub api_key: String,
150 pub permissions: Vec<String>,
151 pub rate_limit_rps: Option<u32>,
152}
153
154impl LocalControlPlane {
155 pub fn new(clients: Vec<LocalClient>) -> Self {
157 Self { clients }
158 }
159
160 fn constant_time_eq(a: &str, b: &str) -> bool {
162 if a.len() != b.len() {
163 return false;
164 }
165 a.as_bytes()
166 .iter()
167 .zip(b.as_bytes().iter())
168 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
169 == 0
170 }
171}
172
173#[cfg(feature = "async")]
174#[async_trait]
175impl ControlPlane for LocalControlPlane {
176 async fn lookup_client(&self, api_key: &str) -> Result<ClientRecord, ControlPlaneError> {
177 for client in &self.clients {
178 if Self::constant_time_eq(&client.api_key, api_key) {
179 return Ok(ClientRecord {
180 client_id: client.client_id.clone(),
181 is_active: true,
182 permissions: client.permissions.clone(),
183 rate_limit_rps: client.rate_limit_rps,
184 metadata: HashMap::new(),
185 });
186 }
187 }
188 Err(ControlPlaneError::NotFound)
189 }
190
191 fn backend_name(&self) -> &str {
192 "local"
193 }
194}
195
196#[cfg(feature = "async")]
205pub struct FallbackControlPlane {
206 primary: Box<dyn ControlPlane>,
207 secondary: Box<dyn ControlPlane>,
208}
209
210#[cfg(feature = "async")]
211impl FallbackControlPlane {
212 pub fn new(primary: Box<dyn ControlPlane>, secondary: Box<dyn ControlPlane>) -> Self {
213 Self { primary, secondary }
214 }
215}
216
217#[cfg(feature = "async")]
218#[async_trait]
219impl ControlPlane for FallbackControlPlane {
220 async fn lookup_client(&self, api_key: &str) -> Result<ClientRecord, ControlPlaneError> {
221 match self.primary.lookup_client(api_key).await {
222 Ok(record) => Ok(record),
223 Err(ControlPlaneError::Inactive) => Err(ControlPlaneError::Inactive),
225 Err(ControlPlaneError::MalformedRecord(msg)) => {
227 Err(ControlPlaneError::MalformedRecord(msg))
228 }
229 Err(_primary_err) => self.secondary.lookup_client(api_key).await,
231 }
232 }
233
234 async fn health_check(&self) -> bool {
235 self.primary.health_check().await || self.secondary.health_check().await
237 }
238
239 fn backend_name(&self) -> &str {
240 "fallback"
241 }
242}
243
244#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn test_hash_api_key_deterministic() {
252 let h1 = hash_api_key("sk-test-key-123");
253 let h2 = hash_api_key("sk-test-key-123");
254 assert_eq!(h1, h2);
255 }
256
257 #[test]
258 fn test_hash_api_key_different_keys_different_hashes() {
259 let h1 = hash_api_key("sk-key-a");
260 let h2 = hash_api_key("sk-key-b");
261 assert_ne!(h1, h2);
262 }
263
264 #[test]
265 fn test_hash_api_key_is_hex() {
266 let h = hash_api_key("sk-test");
267 assert_eq!(h.len(), 64); assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
269 }
270
271 #[test]
272 fn test_hash_api_key_known_value() {
273 let h = hash_api_key("hello");
275 assert_eq!(
276 h,
277 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
278 );
279 }
280
281 #[test]
282 fn test_client_record_deserialize_minimal() {
283 let json = r#"{"client_id": "acme"}"#;
284 let record: ClientRecord = serde_json::from_str(json).unwrap();
285 assert_eq!(record.client_id, "acme");
286 assert!(record.is_active); assert_eq!(record.permissions.len(), 4); assert!(record.rate_limit_rps.is_none());
289 assert!(record.metadata.is_empty());
290 }
291
292 #[test]
293 fn test_client_record_deserialize_full() {
294 let json = r#"{
295 "client_id": "acme",
296 "is_active": false,
297 "permissions": ["read"],
298 "rate_limit_rps": 50,
299 "metadata": {"tier": "enterprise"}
300 }"#;
301 let record: ClientRecord = serde_json::from_str(json).unwrap();
302 assert_eq!(record.client_id, "acme");
303 assert!(!record.is_active);
304 assert_eq!(record.permissions, vec!["read"]);
305 assert_eq!(record.rate_limit_rps, Some(50));
306 assert_eq!(record.metadata.get("tier"), Some(&"enterprise".to_string()));
307 }
308
309 #[test]
310 fn test_client_record_serialize_roundtrip() {
311 let record = ClientRecord {
312 client_id: "test".into(),
313 is_active: true,
314 permissions: vec!["read".into(), "write".into()],
315 rate_limit_rps: Some(100),
316 metadata: HashMap::new(),
317 };
318 let json = serde_json::to_string(&record).unwrap();
319 let back: ClientRecord = serde_json::from_str(&json).unwrap();
320 assert_eq!(back.client_id, "test");
321 assert_eq!(back.permissions, vec!["read", "write"]);
322 assert_eq!(back.rate_limit_rps, Some(100));
323 }
324
325 #[test]
326 fn test_client_record_rate_limit_omitted_in_json() {
327 let record = ClientRecord {
328 client_id: "test".into(),
329 is_active: true,
330 permissions: vec![],
331 rate_limit_rps: None,
332 metadata: HashMap::new(),
333 };
334 let json = serde_json::to_string(&record).unwrap();
335 assert!(!json.contains("rate_limit_rps"));
336 }
337
338 #[test]
339 fn test_client_record_missing_client_id_fails() {
340 let json = r#"{"permissions": ["read"]}"#;
341 let result: Result<ClientRecord, _> = serde_json::from_str(json);
342 assert!(result.is_err());
343 }
344
345 #[tokio::test]
348 async fn test_local_lookup_found() {
349 let cp = LocalControlPlane::new(vec![LocalClient {
350 client_id: "acme".into(),
351 api_key: "sk-acme-123".into(),
352 permissions: vec!["read".into(), "write".into()],
353 rate_limit_rps: Some(100),
354 }]);
355 let record = cp.lookup_client("sk-acme-123").await.unwrap();
356 assert_eq!(record.client_id, "acme");
357 assert!(record.is_active);
358 assert_eq!(record.permissions, vec!["read", "write"]);
359 assert_eq!(record.rate_limit_rps, Some(100));
360 }
361
362 #[tokio::test]
363 async fn test_local_lookup_not_found() {
364 let cp = LocalControlPlane::new(vec![LocalClient {
365 client_id: "acme".into(),
366 api_key: "sk-acme-123".into(),
367 permissions: vec![],
368 rate_limit_rps: None,
369 }]);
370 let result = cp.lookup_client("sk-wrong").await;
371 assert!(matches!(result, Err(ControlPlaneError::NotFound)));
372 }
373
374 #[tokio::test]
375 async fn test_local_lookup_empty_list() {
376 let cp = LocalControlPlane::new(vec![]);
377 let result = cp.lookup_client("sk-anything").await;
378 assert!(matches!(result, Err(ControlPlaneError::NotFound)));
379 }
380
381 #[tokio::test]
382 async fn test_local_lookup_multiple_clients() {
383 let cp = LocalControlPlane::new(vec![
384 LocalClient {
385 client_id: "acme".into(),
386 api_key: "sk-acme".into(),
387 permissions: vec!["read".into()],
388 rate_limit_rps: None,
389 },
390 LocalClient {
391 client_id: "beta".into(),
392 api_key: "sk-beta".into(),
393 permissions: vec!["write".into()],
394 rate_limit_rps: None,
395 },
396 ]);
397 let r1 = cp.lookup_client("sk-acme").await.unwrap();
398 assert_eq!(r1.client_id, "acme");
399
400 let r2 = cp.lookup_client("sk-beta").await.unwrap();
401 assert_eq!(r2.client_id, "beta");
402 }
403
404 #[tokio::test]
405 async fn test_local_constant_time_prevents_substring_match() {
406 let cp = LocalControlPlane::new(vec![LocalClient {
407 client_id: "acme".into(),
408 api_key: "sk-acme-123".into(),
409 permissions: vec![],
410 rate_limit_rps: None,
411 }]);
412 assert!(cp.lookup_client("sk-acme").await.is_err());
414 assert!(cp.lookup_client("sk-acme-1234").await.is_err());
415 assert!(cp.lookup_client("sk-acme-12").await.is_err());
416 }
417
418 #[tokio::test]
419 async fn test_local_backend_name() {
420 let cp = LocalControlPlane::new(vec![]);
421 assert_eq!(cp.backend_name(), "local");
422 }
423
424 #[tokio::test]
425 async fn test_local_health_check() {
426 let cp = LocalControlPlane::new(vec![]);
427 assert!(cp.health_check().await);
428 }
429
430 struct MockControlPlane {
434 name: &'static str,
435 record: Option<ClientRecord>,
436 error: Option<ControlPlaneError>,
437 }
438
439 impl MockControlPlane {
440 fn succeeding(name: &'static str, client_id: &str) -> Self {
441 Self {
442 name,
443 record: Some(ClientRecord {
444 client_id: client_id.into(),
445 is_active: true,
446 permissions: vec!["read".into()],
447 rate_limit_rps: None,
448 metadata: HashMap::new(),
449 }),
450 error: None,
451 }
452 }
453
454 fn failing(name: &'static str, error: ControlPlaneError) -> Self {
455 Self {
456 name,
457 record: None,
458 error: Some(error),
459 }
460 }
461 }
462
463 #[async_trait]
464 impl ControlPlane for MockControlPlane {
465 async fn lookup_client(&self, _api_key: &str) -> Result<ClientRecord, ControlPlaneError> {
466 if let Some(ref record) = self.record {
467 Ok(record.clone())
468 } else if let Some(ref err) = self.error {
469 match err {
471 ControlPlaneError::NotFound => Err(ControlPlaneError::NotFound),
472 ControlPlaneError::Inactive => Err(ControlPlaneError::Inactive),
473 ControlPlaneError::Unreachable(m) => {
474 Err(ControlPlaneError::Unreachable(m.clone()))
475 }
476 ControlPlaneError::MalformedRecord(m) => {
477 Err(ControlPlaneError::MalformedRecord(m.clone()))
478 }
479 ControlPlaneError::Internal(m) => Err(ControlPlaneError::Internal(m.clone())),
480 }
481 } else {
482 Err(ControlPlaneError::NotFound)
483 }
484 }
485
486 fn backend_name(&self) -> &str {
487 self.name
488 }
489 }
490
491 #[tokio::test]
492 async fn test_fallback_primary_succeeds() {
493 let primary = MockControlPlane::succeeding("primary", "from-primary");
494 let secondary = MockControlPlane::succeeding("secondary", "from-secondary");
495 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
496
497 let record = cp.lookup_client("any-key").await.unwrap();
498 assert_eq!(record.client_id, "from-primary");
499 }
500
501 #[tokio::test]
502 async fn test_fallback_primary_not_found_falls_through() {
503 let primary = MockControlPlane::failing("primary", ControlPlaneError::NotFound);
504 let secondary = MockControlPlane::succeeding("secondary", "from-secondary");
505 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
506
507 let record = cp.lookup_client("any-key").await.unwrap();
508 assert_eq!(record.client_id, "from-secondary");
509 }
510
511 #[tokio::test]
512 async fn test_fallback_primary_unreachable_falls_through() {
513 let primary =
514 MockControlPlane::failing("primary", ControlPlaneError::Unreachable("timeout".into()));
515 let secondary = MockControlPlane::succeeding("secondary", "from-secondary");
516 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
517
518 let record = cp.lookup_client("any-key").await.unwrap();
519 assert_eq!(record.client_id, "from-secondary");
520 }
521
522 #[tokio::test]
523 async fn test_fallback_primary_inactive_does_not_fall_through() {
524 let primary = MockControlPlane::failing("primary", ControlPlaneError::Inactive);
525 let secondary = MockControlPlane::succeeding("secondary", "from-secondary");
526 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
527
528 let result = cp.lookup_client("any-key").await;
529 assert!(matches!(result, Err(ControlPlaneError::Inactive)));
530 }
531
532 #[tokio::test]
533 async fn test_fallback_primary_malformed_does_not_fall_through() {
534 let primary = MockControlPlane::failing(
535 "primary",
536 ControlPlaneError::MalformedRecord("bad json".into()),
537 );
538 let secondary = MockControlPlane::succeeding("secondary", "from-secondary");
539 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
540
541 let result = cp.lookup_client("any-key").await;
542 assert!(matches!(result, Err(ControlPlaneError::MalformedRecord(_))));
543 }
544
545 #[tokio::test]
546 async fn test_fallback_both_not_found() {
547 let primary = MockControlPlane::failing("primary", ControlPlaneError::NotFound);
548 let secondary = MockControlPlane::failing("secondary", ControlPlaneError::NotFound);
549 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
550
551 let result = cp.lookup_client("any-key").await;
552 assert!(matches!(result, Err(ControlPlaneError::NotFound)));
553 }
554
555 #[tokio::test]
556 async fn test_fallback_backend_name() {
557 let primary = MockControlPlane::succeeding("primary", "x");
558 let secondary = MockControlPlane::succeeding("secondary", "y");
559 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
560 assert_eq!(cp.backend_name(), "fallback");
561 }
562
563}