Skip to main content

briefcase_core/
control.rs

1//! # Control Plane Abstraction
2//!
3//! Defines the `ControlPlane` trait for looking up client authentication and
4//! authorization records.  Implementations include:
5//!
6//! - **`RemoteControlPlane`**  reads client records from a remote control
7//!   repository (the production source of truth).
8//! - **`LocalControlPlane`**  resolves clients from a static in-memory list
9//!   (useful for dev/test or as a fallback).
10//! - **`FallbackControlPlane`**  chains a *primary* and *secondary* control
11//!   plane: the primary is tried first and, on any error, the secondary is
12//!   consulted.  This lets you run the remote backend as the primary with
13//!   local config as the fallback.
14//!
15//! ## Client record schema
16//!
17//! Records live at `clients/by-key/{sha256(api_key)}.json` in the control
18//! repository.  The JSON schema is defined by [`ClientRecord`].
19//!
20//! ## Extending
21//!
22//! To add a new backend (e.g. PostgreSQL, Redis, Vault), implement the
23//! `ControlPlane` trait and wire it into the server's `AppState`.
24
25use 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//  Errors
34
35#[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//  Client record schema
54
55/// Canonical client record stored in the control plane.
56///
57/// In the remote backend this is the JSON payload at
58/// `clients/by-key/{sha256(api_key)}.json`.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ClientRecord {
61    /// Unique human-readable identifier (e.g. "acme", "internal-tools").
62    pub client_id: String,
63
64    /// Whether the client is currently active.  Inactive clients fail auth
65    /// even if the key matches.
66    #[serde(default = "default_active")]
67    pub is_active: bool,
68
69    /// Granted permission strings (e.g. `["read", "write", "replay", "delete"]`).
70    #[serde(default = "default_permissions")]
71    pub permissions: Vec<String>,
72
73    /// Optional per-client rate limit (requests per second).
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub rate_limit_rps: Option<u32>,
76
77    /// Arbitrary key-value metadata.
78    #[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//  Trait
96
97/// Abstraction over the source of truth for client authentication and
98/// authorization.
99///
100/// Implementors resolve an API key to a [`ClientRecord`].  The server calls
101/// this on the `POST /api/v1/auth/validate` path and, optionally, in the
102/// per-request middleware.
103#[cfg(feature = "async")]
104#[async_trait]
105pub trait ControlPlane: Send + Sync {
106    /// Look up a client by raw API key.
107    ///
108    /// Implementations MUST:
109    /// 1. Hash the key (SHA-256) before any network/storage lookup.
110    /// 2. Return `Err(NotFound)` when no record matches.
111    /// 3. Return `Err(Inactive)` when the record exists but `is_active` is
112    ///    false.
113    async fn lookup_client(&self, api_key: &str) -> Result<ClientRecord, ControlPlaneError>;
114
115    /// Quick connectivity check.  Returns `true` if the backend is reachable.
116    async fn health_check(&self) -> bool {
117        true
118    }
119
120    /// Human-readable backend name for logging (e.g. "remote", "local").
121    fn backend_name(&self) -> &str;
122}
123
124//  Helpers
125
126/// SHA-256 hash of an API key, returned as a lowercase hex string.
127pub 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
135//  Local (in-memory) implementation
136
137/// Resolves clients from a static list  typically parsed from env vars.
138///
139/// This is the simplest control plane: no network calls, no hashing lookup.
140/// It uses constant-time comparison for security.
141pub struct LocalControlPlane {
142    clients: Vec<LocalClient>,
143}
144
145/// A client entry for local (in-memory) lookup.
146#[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    /// Create from a list of client entries.
156    pub fn new(clients: Vec<LocalClient>) -> Self {
157        Self { clients }
158    }
159
160    /// Constant-time string comparison.
161    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//  Fallback (chained) implementation
197
198/// Chains a primary and secondary control plane.
199///
200/// On `lookup_client`, the primary is tried first.  If it returns `NotFound`
201/// or `Unreachable`, the secondary is consulted.  Errors like `Inactive` or
202/// `MalformedRecord` from the primary are NOT retried  those indicate the
203/// record *was* found but is invalid.
204#[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            // Record found but inactive  don't retry, this is authoritative
224            Err(ControlPlaneError::Inactive) => Err(ControlPlaneError::Inactive),
225            // Record found but malformed  don't retry
226            Err(ControlPlaneError::MalformedRecord(msg)) => {
227                Err(ControlPlaneError::MalformedRecord(msg))
228            }
229            // Not found or unreachable  try secondary
230            Err(_primary_err) => self.secondary.lookup_client(api_key).await,
231        }
232    }
233
234    async fn health_check(&self) -> bool {
235        // Healthy if either backend is reachable
236        self.primary.health_check().await || self.secondary.health_check().await
237    }
238
239    fn backend_name(&self) -> &str {
240        "fallback"
241    }
242}
243
244//  Tests
245
246#[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); // SHA-256 = 32 bytes = 64 hex chars
268        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
269    }
270
271    #[test]
272    fn test_hash_api_key_known_value() {
273        // SHA-256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
274        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); // default
287        assert_eq!(record.permissions.len(), 4); // defaults
288        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    // --- LocalControlPlane tests ---
346
347    #[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        // Substring should NOT match
413        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    // --- FallbackControlPlane tests ---
431
432    /// A mock control plane that always returns a fixed record.
433    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                // Recreate the error since Error doesn't impl Clone
470                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}