Skip to main content

fraiseql_server/server_config/
methods.rs

1use super::ServerConfig;
2
3impl ServerConfig {
4    /// Load server configuration from a TOML file.
5    ///
6    /// # Errors
7    ///
8    /// Returns an error string if the file cannot be read or the TOML cannot be parsed.
9    pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, String> {
10        let content = std::fs::read_to_string(path.as_ref())
11            .map_err(|e| format!("Cannot read config file: {e}"))?;
12        toml::from_str(&content).map_err(|e| format!("Invalid TOML config: {e}"))
13    }
14
15    /// Check if running in production mode.
16    ///
17    /// Production mode is detected via `FRAISEQL_ENV` environment variable.
18    /// - `production` or `prod` (or any value other than `development`/`dev`) → production mode
19    /// - `development` or `dev` → development mode
20    #[must_use]
21    pub fn is_production_mode() -> bool {
22        let env = std::env::var("FRAISEQL_ENV")
23            .unwrap_or_else(|_| "production".to_string())
24            .to_lowercase();
25        env != "development" && env != "dev"
26    }
27
28    /// Validate configuration.
29    ///
30    /// # Errors
31    ///
32    /// Returns error if:
33    /// - `metrics_enabled` is true but `metrics_token` is not set
34    /// - `metrics_token` is set but too short (< 16 characters)
35    /// - `auth` config is set but invalid (e.g., empty issuer)
36    /// - `tls` is enabled but cert or key path is missing
37    /// - TLS minimum version is invalid
38    /// - In production mode: `playground_enabled` is true
39    /// - In production mode: `cors_enabled` is true but `cors_origins` is empty
40    pub fn validate(&self) -> Result<(), String> {
41        if self.metrics_enabled {
42            match &self.metrics_token {
43                None => {
44                    return Err("metrics_enabled is true but metrics_token is not set. \
45                         Set FRAISEQL_METRICS_TOKEN or metrics_token in config."
46                        .to_string());
47                },
48                Some(token) if token.len() < 16 => {
49                    return Err(
50                        "metrics_token must be at least 16 characters for security.".to_string()
51                    );
52                },
53                Some(_) => {},
54            }
55        }
56
57        // Admin API validation
58        if self.admin_api_enabled {
59            match &self.admin_token {
60                None => {
61                    return Err("admin_api_enabled is true but admin_token is not set. \
62                         Set FRAISEQL_ADMIN_TOKEN or admin_token in config."
63                        .to_string());
64                },
65                Some(token) if token.len() < 32 => {
66                    return Err(
67                        "admin_token must be at least 32 characters for security.".to_string()
68                    );
69                },
70                Some(_) => {},
71            }
72
73            // Validate the optional read-only token when provided.
74            if let Some(ref ro_token) = self.admin_readonly_token {
75                if ro_token.len() < 32 {
76                    return Err(
77                        "admin_readonly_token must be at least 32 characters for security."
78                            .to_string(),
79                    );
80                }
81                if Some(ro_token) == self.admin_token.as_ref() {
82                    return Err("admin_readonly_token must differ from admin_token.".to_string());
83                }
84            }
85        }
86
87        // Validate OIDC config if present
88        if let Some(ref auth) = self.auth {
89            auth.validate().map_err(|e| e.to_string())?;
90        }
91
92        // Validate TLS config if present and enabled
93        if let Some(ref tls) = self.tls {
94            if tls.enabled {
95                if !tls.cert_path.exists() {
96                    return Err(format!(
97                        "TLS enabled but certificate file not found: {}",
98                        tls.cert_path.display()
99                    ));
100                }
101                if !tls.key_path.exists() {
102                    return Err(format!(
103                        "TLS enabled but key file not found: {}",
104                        tls.key_path.display()
105                    ));
106                }
107
108                // Validate TLS version
109                if !["1.2", "1.3"].contains(&tls.min_version.as_str()) {
110                    return Err("TLS min_version must be '1.2' or '1.3'".to_string());
111                }
112
113                // Validate mTLS config if required
114                if tls.require_client_cert {
115                    if let Some(ref ca_path) = tls.client_ca_path {
116                        if !ca_path.exists() {
117                            return Err(format!("Client CA file not found: {}", ca_path.display()));
118                        }
119                    } else {
120                        return Err(
121                            "require_client_cert is true but client_ca_path is not set".to_string()
122                        );
123                    }
124                }
125            }
126        }
127
128        // Pool invariants
129        if self.pool_max_size == 0 {
130            return Err("pool_max_size must be at least 1".to_string());
131        }
132        if self.pool_min_size > self.pool_max_size {
133            return Err(format!(
134                "pool_min_size ({}) must not exceed pool_max_size ({})",
135                self.pool_min_size, self.pool_max_size
136            ));
137        }
138        if self.pool_timeout_secs == 0 {
139            return Err(
140                "pool_timeout_secs must be > 0. A zero-second timeout would cause every \
141                 connection acquisition to fail immediately. Use a positive value (e.g. 30) \
142                 or remove the field to use the default (30s)."
143                    .to_string(),
144            );
145        }
146
147        // Validate database TLS config if present
148        if let Some(ref db_tls) = self.database_tls {
149            // Validate PostgreSQL SSL mode
150            if ![
151                "disable",
152                "allow",
153                "prefer",
154                "require",
155                "verify-ca",
156                "verify-full",
157            ]
158            .contains(&db_tls.postgres_ssl_mode.as_str())
159            {
160                return Err("Invalid postgres_ssl_mode. Must be one of: \
161                     disable, allow, prefer, require, verify-ca, verify-full"
162                    .to_string());
163            }
164
165            // Validate CA bundle path if provided
166            if let Some(ref ca_path) = db_tls.ca_bundle_path {
167                if !ca_path.exists() {
168                    return Err(format!("CA bundle file not found: {}", ca_path.display()));
169                }
170            }
171        }
172
173        // Rate limiting sanity check
174        if let Some(ref rl) = self.rate_limiting {
175            if rl.rps_per_ip > 0 && rl.rps_per_user > 0 && rl.rps_per_ip > rl.rps_per_user {
176                tracing::warn!(
177                    rps_per_ip = rl.rps_per_ip,
178                    rps_per_user = rl.rps_per_user,
179                    "rps_per_ip exceeds rps_per_user — authenticated users are more \
180                     restricted than anonymous IPs"
181                );
182            }
183        }
184
185        // Production safety validation
186        if Self::is_production_mode() {
187            // Playground should be disabled in production
188            if self.playground_enabled {
189                return Err("playground_enabled is true in production mode. \
190                     Disable the playground or set FRAISEQL_ENV=development. \
191                     The playground exposes sensitive schema information."
192                    .to_string());
193            }
194
195            // CORS origins must be explicitly configured in production
196            if self.cors_enabled && self.cors_origins.is_empty() {
197                return Err("cors_enabled is true but cors_origins is empty in production mode. \
198                     This allows requests from ANY origin, which is a security risk. \
199                     Explicitly configure cors_origins with your allowed domains, \
200                     or disable CORS and set FRAISEQL_ENV=development to bypass this check."
201                    .to_string());
202            }
203        }
204
205        Ok(())
206    }
207
208    /// Check if authentication is enabled.
209    #[must_use]
210    pub const fn auth_enabled(&self) -> bool {
211        self.auth.is_some()
212    }
213}