Skip to main content

postrust_core/
config.rs

1//! Configuration for Postrust.
2//!
3//! Mirrors PostgREST's configuration options.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Main application configuration.
9#[derive(Clone, Debug, Serialize, Deserialize)]
10pub struct AppConfig {
11    // ========================================================================
12    // Database Settings
13    // ========================================================================
14    /// PostgreSQL connection URI
15    #[serde(default = "default_db_uri")]
16    pub db_uri: String,
17
18    /// Schemas to expose via the API
19    #[serde(default = "default_db_schemas")]
20    pub db_schemas: Vec<String>,
21
22    /// Role for unauthenticated requests
23    pub db_anon_role: Option<String>,
24
25    /// Connection pool size
26    #[serde(default = "default_pool_size")]
27    pub db_pool_size: u32,
28
29    /// Pool acquisition timeout in seconds
30    #[serde(default = "default_pool_timeout")]
31    pub db_pool_timeout: u64,
32
33    /// Use prepared statements
34    #[serde(default = "default_true")]
35    pub db_prepared_statements: bool,
36
37    /// Schemas added to the `search_path` of every request, after the
38    /// request's own schema.
39    ///
40    /// These are not exposed as API endpoints; they are where a function an
41    /// exposed one calls, or a type it references, is allowed to live. An
42    /// aggregate returning a domain declared elsewhere cannot resolve that
43    /// domain without this. `public` by default, as in PostgREST.
44    #[serde(default = "default_extra_search_path")]
45    pub db_extra_search_path: Vec<String>,
46
47    /// LISTEN/NOTIFY channel for schema reload
48    #[serde(default = "default_db_channel")]
49    pub db_channel: String,
50
51    /// Enable NOTIFY-based schema cache reload
52    #[serde(default)]
53    pub db_channel_enabled: bool,
54
55    /// Pre-request function to call
56    pub db_pre_request: Option<String>,
57
58    /// Maximum rows allowed in a response
59    pub db_max_rows: Option<i64>,
60
61    /// Enable aggregate functions in `select`.
62    ///
63    /// Off by default, as in PostgREST: an aggregate over a large table costs
64    /// far more than the request looks like it asks for.
65    #[serde(default)]
66    pub db_aggregates_enabled: bool,
67
68    // ========================================================================
69    // Server Settings
70    // ========================================================================
71    /// Server host to bind
72    #[serde(default = "default_host")]
73    pub server_host: String,
74
75    /// Server port
76    #[serde(default = "default_port")]
77    pub server_port: u16,
78
79    /// Unix socket path (alternative to host/port)
80    pub server_unix_socket: Option<String>,
81
82    /// Admin server port (for health checks)
83    pub admin_server_port: Option<u16>,
84
85    // ========================================================================
86    // JWT Settings
87    // ========================================================================
88    /// JWT secret key (or JWKS URL)
89    pub jwt_secret: Option<String>,
90
91    /// JWT secret as base64
92    #[serde(default)]
93    pub jwt_secret_is_base64: bool,
94
95    /// JWT audience claim to validate
96    pub jwt_aud: Option<String>,
97
98    /// JWT claim that contains the role
99    #[serde(default = "default_jwt_role_claim")]
100    pub jwt_role_claim_key: String,
101
102    /// Cache JWT validations
103    #[serde(default = "default_true")]
104    pub jwt_cache_enabled: bool,
105
106    /// JWT cache max entries
107    #[serde(default = "default_jwt_cache_max")]
108    pub jwt_cache_max_lifetime: u64,
109
110    // ========================================================================
111    // Hasura Authentication
112    // ========================================================================
113    /// The shared secret that authenticates an administrator, and the switch
114    /// that makes `x-hasura-*` headers mean anything at all.
115    ///
116    /// Unset, this server ignores those headers and reads session variables
117    /// only from a verified token -- because honouring a header nothing
118    /// authenticated would let any caller name its own identity, and a policy
119    /// reading a value the caller chose is not a policy. Hasura instead treats
120    /// an unsecured deployment as wholly administrative; the divergence is
121    /// deliberate and is recorded in `postrust_auth::hasura`.
122    pub hasura_admin_secret: Option<String>,
123
124    /// The role a request falls to when nothing authenticated it. Hasura's
125    /// `HASURA_GRAPHQL_UNAUTHORIZED_ROLE`; unset means such a request is
126    /// refused rather than answered as a stranger.
127    pub hasura_unauthorized_role: Option<String>,
128
129    // ========================================================================
130    // OpenAPI Settings
131    // ========================================================================
132    /// OpenAPI server URL
133    pub openapi_server_proxy_uri: Option<String>,
134
135    /// OpenAPI mode: disabled, follow-privileges, ignore-privileges, security-definer
136    #[serde(default = "default_openapi_mode")]
137    pub openapi_mode: OpenApiMode,
138
139    // ========================================================================
140    // Logging Settings
141    // ========================================================================
142    /// Log level: crit, error, warn, info, debug
143    #[serde(default = "default_log_level")]
144    pub log_level: LogLevel,
145
146    // ========================================================================
147    // Role Settings
148    // ========================================================================
149    /// Per-role settings (isolation level, timeout)
150    #[serde(default)]
151    pub role_settings: HashMap<String, RoleSettings>,
152
153    /// App-level settings to expose via GUC
154    #[serde(default)]
155    pub app_settings: HashMap<String, String>,
156
157    // ========================================================================
158    // Compatibility Settings
159    // ========================================================================
160    /// PostgREST compatibility mode.
161    ///
162    /// When enabled, the REST surface is also served at the root (so canonical
163    /// PostgREST paths like `/rpc/<name>` and `/<table>` work in addition to
164    /// the `/api`-prefixed paths), and RPC responses are un-wrapped to match
165    /// PostgREST's shape (bare object/scalar for non-set-returning functions,
166    /// a top-level array for set-returning ones) instead of the array-wrapped,
167    /// function-name-keyed default.
168    #[serde(default)]
169    pub compat_mode: bool,
170}
171
172impl Default for AppConfig {
173    fn default() -> Self {
174        Self {
175            db_uri: default_db_uri(),
176            db_schemas: default_db_schemas(),
177            db_anon_role: None,
178            db_pool_size: default_pool_size(),
179            db_pool_timeout: default_pool_timeout(),
180            db_prepared_statements: true,
181            db_extra_search_path: default_extra_search_path(),
182            db_channel: default_db_channel(),
183            db_channel_enabled: false,
184            db_pre_request: None,
185            db_max_rows: None,
186            db_aggregates_enabled: false,
187            server_host: default_host(),
188            server_port: default_port(),
189            server_unix_socket: None,
190            admin_server_port: None,
191            jwt_secret: None,
192            jwt_secret_is_base64: false,
193            jwt_aud: None,
194            jwt_role_claim_key: default_jwt_role_claim(),
195            jwt_cache_enabled: true,
196            jwt_cache_max_lifetime: default_jwt_cache_max(),
197            hasura_admin_secret: None,
198            hasura_unauthorized_role: None,
199            openapi_server_proxy_uri: None,
200            openapi_mode: OpenApiMode::FollowPrivileges,
201            log_level: LogLevel::Error,
202            role_settings: HashMap::new(),
203            app_settings: HashMap::new(),
204            compat_mode: false,
205        }
206    }
207}
208
209impl AppConfig {
210    /// Load configuration from environment variables.
211    pub fn from_env() -> Self {
212        let mut config = Self::default();
213
214        if let Ok(uri) = std::env::var("PGRST_DB_URI") {
215            config.db_uri = uri;
216        }
217        if let Ok(uri) = std::env::var("DATABASE_URL") {
218            config.db_uri = uri;
219        }
220        if let Ok(schemas) = std::env::var("PGRST_DB_SCHEMAS") {
221            config.db_schemas = schemas.split(',').map(|s| s.trim().to_string()).collect();
222        }
223        if let Ok(role) = std::env::var("PGRST_DB_ANON_ROLE") {
224            config.db_anon_role = Some(role);
225        }
226        if let Ok(size) = std::env::var("PGRST_DB_POOL") {
227            if let Ok(n) = size.parse() {
228                config.db_pool_size = n;
229            }
230        }
231        // `PGRST_MAX_ROWS` is the name used in our own documentation;
232        if let Ok(v) = std::env::var("PGRST_DB_EXTRA_SEARCH_PATH") {
233            config.db_extra_search_path = v
234                .split(',')
235                .map(|s| s.trim().to_string())
236                .filter(|s| !s.is_empty())
237                .collect();
238        }
239
240        if let Ok(v) = std::env::var("PGRST_DB_AGGREGATES_ENABLED") {
241            config.db_aggregates_enabled = matches!(v.to_ascii_lowercase().as_str(), "1" | "true");
242        }
243
244        // `PGRST_DB_MAX_ROWS` mirrors PostgREST's `db-max-rows`. Accept both.
245        for var in ["PGRST_DB_MAX_ROWS", "PGRST_MAX_ROWS"] {
246            if let Ok(max_rows) = std::env::var(var) {
247                match max_rows.parse::<i64>() {
248                    Ok(n) if n >= 0 => config.db_max_rows = Some(n),
249                    _ => {
250                        tracing::warn!(
251                            "Ignoring {}={:?}: expected a non-negative integer",
252                            var,
253                            max_rows
254                        );
255                    }
256                }
257            }
258        }
259        if let Ok(secret) = std::env::var("PGRST_JWT_SECRET") {
260            config.jwt_secret = Some(secret);
261        }
262        if let Ok(aud) = std::env::var("PGRST_JWT_AUD") {
263            config.jwt_aud = Some(aud);
264        }
265        // Hasura's own spelling is accepted beside ours, because a deployment
266        // migrating from it has these in a compose file already and the point
267        // of the mode is that the client does not have to be rewritten. The
268        // `PGRST_` name wins where both are set, on the grounds that it is the
269        // more specific instruction to this server.
270        for var in ["HASURA_GRAPHQL_ADMIN_SECRET", "PGRST_HASURA_ADMIN_SECRET"] {
271            if let Ok(secret) = std::env::var(var) {
272                if !secret.is_empty() {
273                    config.hasura_admin_secret = Some(secret);
274                }
275            }
276        }
277        for var in [
278            "HASURA_GRAPHQL_UNAUTHORIZED_ROLE",
279            "PGRST_HASURA_UNAUTHORIZED_ROLE",
280        ] {
281            if let Ok(role) = std::env::var(var) {
282                if !role.is_empty() {
283                    config.hasura_unauthorized_role = Some(role);
284                }
285            }
286        }
287        if let Ok(host) = std::env::var("PGRST_SERVER_HOST") {
288            config.server_host = host;
289        }
290        if let Ok(port) = std::env::var("PGRST_SERVER_PORT") {
291            if let Ok(p) = port.parse() {
292                config.server_port = p;
293            }
294        }
295        if let Ok(port) = std::env::var("PORT") {
296            if let Ok(p) = port.parse() {
297                config.server_port = p;
298            }
299        }
300        // Accept either the PGRST_-prefixed name (for parity with other options)
301        // or a POSTRUST_-prefixed alias.
302        for var in ["PGRST_COMPAT_MODE", "POSTRUST_COMPAT_MODE"] {
303            if let Ok(v) = std::env::var(var) {
304                config.compat_mode = matches!(
305                    v.trim().to_ascii_lowercase().as_str(),
306                    "true" | "1" | "yes" | "on"
307                );
308            }
309        }
310
311        config
312    }
313
314    /// Get the default schema (first in the list).
315    pub fn default_schema(&self) -> &str {
316        self.db_schemas
317            .first()
318            .map(|s| s.as_str())
319            .unwrap_or("public")
320    }
321}
322
323/// Per-role settings.
324#[derive(Clone, Debug, Serialize, Deserialize)]
325pub struct RoleSettings {
326    /// Isolation level for this role
327    pub isolation_level: Option<IsolationLevel>,
328    /// Statement timeout in milliseconds
329    pub statement_timeout: Option<u64>,
330}
331
332/// Transaction isolation levels.
333#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
334pub enum IsolationLevel {
335    ReadCommitted,
336    RepeatableRead,
337    Serializable,
338}
339
340impl IsolationLevel {
341    pub fn to_sql(&self) -> &'static str {
342        match self {
343            Self::ReadCommitted => "READ COMMITTED",
344            Self::RepeatableRead => "REPEATABLE READ",
345            Self::Serializable => "SERIALIZABLE",
346        }
347    }
348}
349
350/// OpenAPI generation mode.
351#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
352pub enum OpenApiMode {
353    Disabled,
354    FollowPrivileges,
355    IgnorePrivileges,
356    SecurityDefiner,
357}
358
359/// Log levels.
360#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
361pub enum LogLevel {
362    Crit,
363    Error,
364    Warn,
365    Info,
366    Debug,
367}
368
369impl LogLevel {
370    pub fn to_tracing(&self) -> tracing::Level {
371        match self {
372            Self::Crit | Self::Error => tracing::Level::ERROR,
373            Self::Warn => tracing::Level::WARN,
374            Self::Info => tracing::Level::INFO,
375            Self::Debug => tracing::Level::DEBUG,
376        }
377    }
378}
379
380// Default value functions
381fn default_db_uri() -> String {
382    "postgresql://localhost/postgres".to_string()
383}
384
385fn default_db_schemas() -> Vec<String> {
386    vec!["public".to_string()]
387}
388
389fn default_pool_size() -> u32 {
390    10
391}
392
393fn default_pool_timeout() -> u64 {
394    10
395}
396
397fn default_extra_search_path() -> Vec<String> {
398    vec!["public".to_string()]
399}
400
401fn default_db_channel() -> String {
402    "pgrst".to_string()
403}
404
405fn default_host() -> String {
406    "127.0.0.1".to_string()
407}
408
409fn default_port() -> u16 {
410    3000
411}
412
413fn default_jwt_role_claim() -> String {
414    "role".to_string()
415}
416
417fn default_jwt_cache_max() -> u64 {
418    3600
419}
420
421fn default_openapi_mode() -> OpenApiMode {
422    OpenApiMode::FollowPrivileges
423}
424
425fn default_log_level() -> LogLevel {
426    LogLevel::Error
427}
428
429fn default_true() -> bool {
430    true
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn test_default_config() {
439        let config = AppConfig::default();
440        assert_eq!(config.server_port, 3000);
441        assert_eq!(config.db_pool_size, 10);
442        assert!(config.db_prepared_statements);
443    }
444
445    #[test]
446    fn test_default_schema() {
447        let mut config = AppConfig::default();
448        assert_eq!(config.default_schema(), "public");
449
450        config.db_schemas = vec!["api".to_string(), "public".to_string()];
451        assert_eq!(config.default_schema(), "api");
452    }
453
454    #[test]
455    fn test_isolation_level_sql() {
456        assert_eq!(IsolationLevel::ReadCommitted.to_sql(), "READ COMMITTED");
457        assert_eq!(IsolationLevel::Serializable.to_sql(), "SERIALIZABLE");
458    }
459}