Skip to main content

fabryk_cli/
config_sections.rs

1//! Reusable configuration section structs for Fabryk-based MCP servers.
2//!
3//! These types represent common configuration concerns shared across
4//! multiple Fabryk applications. Projects embed them as named fields
5//! in their domain-specific `Config` struct.
6
7use fabryk_core::{Error, Result};
8use serde::{Deserialize, Serialize};
9
10// ============================================================================
11// TLS configuration
12// ============================================================================
13
14/// TLS configuration with paired cert/key validation.
15///
16/// Both `cert_path` and `key_path` must be set together (or both unset).
17/// When set, the files must exist on disk.
18///
19/// # Example
20///
21/// ```
22/// use fabryk_cli::config_sections::TlsConfig;
23///
24/// let tls = TlsConfig::default();
25/// assert!(!tls.enabled());
26/// ```
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct TlsConfig {
29    /// Path to TLS certificate PEM file. When set with key_path, enables HTTPS.
30    #[serde(default)]
31    pub cert_path: Option<String>,
32
33    /// Path to TLS private key PEM file. When set with cert_path, enables HTTPS.
34    #[serde(default)]
35    pub key_path: Option<String>,
36}
37
38impl TlsConfig {
39    /// Returns true if TLS is configured (both cert and key paths are set and non-empty).
40    pub fn enabled(&self) -> bool {
41        self.cert_path.as_ref().is_some_and(|p| !p.is_empty())
42            && self.key_path.as_ref().is_some_and(|p| !p.is_empty())
43    }
44
45    /// Validate TLS configuration.
46    ///
47    /// Checks that:
48    /// - cert_path and key_path are both set or both unset
49    /// - When set, both files exist on disk
50    ///
51    /// Returns `Ok(())` on success, or an error describing the issue.
52    pub fn validate(&self) -> Result<()> {
53        let has_cert = self.cert_path.as_ref().is_some_and(|p| !p.is_empty());
54        let has_key = self.key_path.as_ref().is_some_and(|p| !p.is_empty());
55
56        if has_cert != has_key {
57            return Err(Error::config(
58                "tls.cert_path and tls.key_path must both be set (or both unset). \
59                 Only one was provided.",
60            ));
61        }
62
63        if has_cert && has_key {
64            let cert_path = self.cert_path.as_ref().unwrap();
65            let key_path = self.key_path.as_ref().unwrap();
66
67            if !std::path::Path::new(cert_path).exists() {
68                return Err(Error::config(format!(
69                    "tls.cert_path '{cert_path}' does not exist"
70                )));
71            }
72            if !std::path::Path::new(key_path).exists() {
73                return Err(Error::config(format!(
74                    "tls.key_path '{key_path}' does not exist"
75                )));
76            }
77
78            log::info!("TLS enabled — cert: {cert_path}, key: {key_path}");
79        }
80
81        Ok(())
82    }
83}
84
85// ============================================================================
86// OAuth2 configuration
87// ============================================================================
88
89/// Default Google JWKS URL for fetching public keys.
90fn default_jwks_url() -> String {
91    "https://www.googleapis.com/oauth2/v3/certs".to_string()
92}
93
94/// OAuth2 authentication configuration (Google-style).
95///
96/// When `enabled` is true, `client_id` is required. The `domain` field
97/// optionally restricts access to a specific email domain.
98///
99/// # Example
100///
101/// ```
102/// use fabryk_cli::config_sections::OAuthConfig;
103///
104/// let oauth = OAuthConfig::default();
105/// assert!(!oauth.enabled);
106/// ```
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct OAuthConfig {
109    /// Enable OAuth2 authentication (default: false for dev mode).
110    #[serde(default)]
111    pub enabled: bool,
112
113    /// Google OAuth2 client ID (required when enabled is true).
114    #[serde(default)]
115    pub client_id: String,
116
117    /// Allowed email domain (e.g., "banyan.com").
118    #[serde(default)]
119    pub domain: String,
120
121    /// JWKS URL for fetching Google public keys.
122    #[serde(default = "default_jwks_url")]
123    pub jwks_url: String,
124}
125
126impl Default for OAuthConfig {
127    fn default() -> Self {
128        Self {
129            enabled: false,
130            client_id: String::new(),
131            domain: String::new(),
132            jwks_url: default_jwks_url(),
133        }
134    }
135}
136
137impl OAuthConfig {
138    /// Validate OAuth configuration.
139    ///
140    /// When enabled:
141    /// - `client_id` must be non-empty (hard error)
142    /// - `domain` should be set (logged warning)
143    ///
144    /// When disabled, no validation is performed.
145    pub fn validate(&self, env_prefix: &str) -> Result<()> {
146        if !self.enabled {
147            return Ok(());
148        }
149
150        if self.client_id.is_empty() {
151            return Err(Error::config(format!(
152                "oauth.client_id is required when oauth.enabled is true. \
153                 Set {env_prefix}_OAUTH_CLIENT_ID or [oauth] client_id in config file."
154            )));
155        }
156
157        if self.domain.is_empty() {
158            log::warn!(
159                "oauth.domain is not set — any Google account can authenticate. \
160                 Set {env_prefix}_OAUTH_DOMAIN to restrict access."
161            );
162        }
163
164        Ok(())
165    }
166}
167
168// ============================================================================
169// Tests
170// ============================================================================
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    // -- TlsConfig tests --
177
178    #[test]
179    fn test_tls_default_disabled() {
180        let tls = TlsConfig::default();
181        assert!(!tls.enabled());
182    }
183
184    #[test]
185    fn test_tls_enabled_both_set() {
186        let tls = TlsConfig {
187            cert_path: Some("/path/to/cert.pem".to_string()),
188            key_path: Some("/path/to/key.pem".to_string()),
189        };
190        assert!(tls.enabled());
191    }
192
193    #[test]
194    fn test_tls_disabled_empty_strings() {
195        let tls = TlsConfig {
196            cert_path: Some(String::new()),
197            key_path: Some(String::new()),
198        };
199        assert!(!tls.enabled());
200    }
201
202    #[test]
203    fn test_tls_disabled_only_cert() {
204        let tls = TlsConfig {
205            cert_path: Some("/path/cert.pem".to_string()),
206            key_path: None,
207        };
208        assert!(!tls.enabled());
209    }
210
211    #[test]
212    fn test_tls_validate_both_unset_ok() {
213        let tls = TlsConfig::default();
214        assert!(tls.validate().is_ok());
215    }
216
217    #[test]
218    fn test_tls_validate_asymmetric_error() {
219        let tls = TlsConfig {
220            cert_path: Some("/path/cert.pem".to_string()),
221            key_path: None,
222        };
223        let err = tls.validate().unwrap_err();
224        assert!(err.to_string().contains("both be set"));
225    }
226
227    #[test]
228    fn test_tls_validate_nonexistent_cert() {
229        let tls = TlsConfig {
230            cert_path: Some("/nonexistent/cert.pem".to_string()),
231            key_path: Some("/nonexistent/key.pem".to_string()),
232        };
233        let err = tls.validate().unwrap_err();
234        assert!(err.to_string().contains("does not exist"));
235    }
236
237    #[test]
238    fn test_tls_validate_existing_files() {
239        let dir = tempfile::TempDir::new().unwrap();
240        let cert = dir.path().join("cert.pem");
241        let key = dir.path().join("key.pem");
242        std::fs::write(&cert, "cert").unwrap();
243        std::fs::write(&key, "key").unwrap();
244
245        let tls = TlsConfig {
246            cert_path: Some(cert.to_str().unwrap().to_string()),
247            key_path: Some(key.to_str().unwrap().to_string()),
248        };
249        assert!(tls.validate().is_ok());
250    }
251
252    // -- OAuthConfig tests --
253
254    #[test]
255    fn test_oauth_default_disabled() {
256        let oauth = OAuthConfig::default();
257        assert!(!oauth.enabled);
258        assert!(oauth.client_id.is_empty());
259        assert!(!oauth.jwks_url.is_empty());
260    }
261
262    #[test]
263    fn test_oauth_validate_disabled_ok() {
264        let oauth = OAuthConfig::default();
265        assert!(oauth.validate("APP").is_ok());
266    }
267
268    #[test]
269    fn test_oauth_validate_enabled_no_client_id() {
270        let oauth = OAuthConfig {
271            enabled: true,
272            ..Default::default()
273        };
274        let err = oauth.validate("APP").unwrap_err();
275        assert!(err.to_string().contains("client_id"));
276    }
277
278    #[test]
279    fn test_oauth_validate_enabled_with_client_id() {
280        let oauth = OAuthConfig {
281            enabled: true,
282            client_id: "my-client-id".to_string(),
283            domain: "example.com".to_string(),
284            ..Default::default()
285        };
286        assert!(oauth.validate("APP").is_ok());
287    }
288
289    #[test]
290    fn test_oauth_default_jwks_url() {
291        let oauth = OAuthConfig::default();
292        assert!(oauth.jwks_url.contains("googleapis.com"));
293    }
294
295    // -- Serialization round-trip --
296
297    #[test]
298    fn test_tls_config_serde_roundtrip() {
299        let tls = TlsConfig {
300            cert_path: Some("/cert.pem".to_string()),
301            key_path: Some("/key.pem".to_string()),
302        };
303        let toml_str = toml::to_string_pretty(&tls).unwrap();
304        let parsed: TlsConfig = toml::from_str(&toml_str).unwrap();
305        assert_eq!(parsed.cert_path, tls.cert_path);
306        assert_eq!(parsed.key_path, tls.key_path);
307    }
308
309    #[test]
310    fn test_oauth_config_serde_roundtrip() {
311        let oauth = OAuthConfig {
312            enabled: true,
313            client_id: "test-id".to_string(),
314            domain: "test.com".to_string(),
315            ..Default::default()
316        };
317        let toml_str = toml::to_string_pretty(&oauth).unwrap();
318        let parsed: OAuthConfig = toml::from_str(&toml_str).unwrap();
319        assert_eq!(parsed.enabled, oauth.enabled);
320        assert_eq!(parsed.client_id, oauth.client_id);
321        assert_eq!(parsed.domain, oauth.domain);
322        assert_eq!(parsed.jwks_url, oauth.jwks_url);
323    }
324}