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    /// Isolation level for the transaction every request runs in.
34    ///
35    /// `READ COMMITTED` unless set, which is PostgreSQL's own default; a
36    /// stricter level is issued as `SET TRANSACTION ISOLATION LEVEL` on the
37    /// request's transaction.
38    #[serde(default = "default_tx_isolation")]
39    pub db_tx_isolation: IsolationLevel,
40
41    /// Use prepared statements
42    #[serde(default = "default_true")]
43    pub db_prepared_statements: bool,
44
45    /// Schemas added to the `search_path` of every request, after the
46    /// request's own schema.
47    ///
48    /// These are not exposed as API endpoints; they are where a function an
49    /// exposed one calls, or a type it references, is allowed to live. An
50    /// aggregate returning a domain declared elsewhere cannot resolve that
51    /// domain without this. `public` by default, as in PostgREST.
52    #[serde(default = "default_extra_search_path")]
53    pub db_extra_search_path: Vec<String>,
54
55    /// LISTEN/NOTIFY channel for schema reload
56    #[serde(default = "default_db_channel")]
57    pub db_channel: String,
58
59    /// Enable NOTIFY-based schema cache reload
60    #[serde(default)]
61    pub db_channel_enabled: bool,
62
63    /// Function called on the request's transaction before its own query,
64    /// after every setting is applied. Anything it raises aborts the request.
65    pub db_pre_request: Option<String>,
66
67    /// Maximum rows allowed in a response
68    pub db_max_rows: Option<i64>,
69
70    /// Enable aggregate functions in `select`.
71    ///
72    /// Off by default, as in PostgREST: an aggregate over a large table costs
73    /// far more than the request looks like it asks for.
74    #[serde(default)]
75    pub db_aggregates_enabled: bool,
76
77    // ========================================================================
78    // Server Settings
79    // ========================================================================
80    /// Server host to bind
81    #[serde(default = "default_host")]
82    pub server_host: String,
83
84    /// Server port
85    #[serde(default = "default_port")]
86    pub server_port: u16,
87
88    /// Origins allowed by CORS.
89    ///
90    /// Empty means every origin is allowed, which is the default and what
91    /// `PGRST_SERVER_CORS_ORIGINS="*"` selects explicitly. A non-empty list
92    /// restricts the `Access-Control-Allow-Origin` response to exactly those
93    /// origins; anything else gets no CORS headers and is refused by the
94    /// browser.
95    #[serde(default)]
96    pub server_cors_origins: Vec<String>,
97
98    /// Maximum accepted request body, in bytes.
99    ///
100    /// The REST handler applies this when it reads the body; the routes that
101    /// take a body extractor get it through axum's `DefaultBodyLimit`. Before
102    /// this was read, the limit in force was a 10 MiB constant in the request
103    /// handler, whatever the variable said.
104    #[serde(default = "default_max_body_size")]
105    pub max_body_size: usize,
106
107    /// Listen on a Unix domain socket instead of host and port.
108    pub server_unix_socket: Option<String>,
109
110    /// Serve liveness and readiness on a second port of their own.
111    pub admin_server_port: Option<u16>,
112
113    // ========================================================================
114    // JWT Settings
115    // ========================================================================
116    /// JWT secret key (or JWKS URL)
117    pub jwt_secret: Option<String>,
118
119    /// JWT secret as base64
120    #[serde(default)]
121    pub jwt_secret_is_base64: bool,
122
123    /// JWT audience claim to validate
124    pub jwt_aud: Option<String>,
125
126    /// JWT claim that contains the role
127    #[serde(default = "default_jwt_role_claim")]
128    pub jwt_role_claim_key: String,
129
130    /// Cache validated tokens, so a repeated token is not verified again.
131    #[serde(default = "default_true")]
132    pub jwt_cache_enabled: bool,
133
134    /// How long a cached validation may be reused, in seconds.
135    ///
136    /// A token's own `exp` bounds this; the cache never answers with a token
137    /// past its expiry. `0` turns the cache off, as in PostgREST.
138    #[serde(default = "default_jwt_cache_max")]
139    pub jwt_cache_max_lifetime: u64,
140
141    // ========================================================================
142    // Hasura Authentication
143    // ========================================================================
144    /// The shared secret that authenticates an administrator, and the switch
145    /// that makes `x-hasura-*` headers mean anything at all.
146    ///
147    /// Unset, this server ignores those headers and reads session variables
148    /// only from a verified token -- because honouring a header nothing
149    /// authenticated would let any caller name its own identity, and a policy
150    /// reading a value the caller chose is not a policy. Hasura instead treats
151    /// an unsecured deployment as wholly administrative; the divergence is
152    /// deliberate and is recorded in `postrust_auth::hasura`.
153    pub hasura_admin_secret: Option<String>,
154
155    /// The role a request falls to when nothing authenticated it. Hasura's
156    /// `HASURA_GRAPHQL_UNAUTHORIZED_ROLE`; unset means such a request is
157    /// refused rather than answered as a stranger.
158    pub hasura_unauthorized_role: Option<String>,
159
160    // ========================================================================
161    // OpenAPI Settings
162    // ========================================================================
163    /// Address to advertise in the specification's `servers`, for when the
164    /// server sits behind a reverse proxy and its own address is not the one
165    /// clients use.
166    pub openapi_server_proxy_uri: Option<String>,
167
168    /// OpenAPI mode: disabled, follow-privileges, ignore-privileges
169    #[serde(default = "default_openapi_mode")]
170    pub openapi_mode: OpenApiMode,
171
172    // ========================================================================
173    // Logging Settings
174    // ========================================================================
175    /// Log level: crit, error, warn, info, debug
176    #[serde(default = "default_log_level")]
177    pub log_level: LogLevel,
178
179    // ========================================================================
180    // Role Settings
181    // ========================================================================
182    /// Per-role settings (isolation level, timeout)
183    #[serde(default)]
184    pub role_settings: HashMap<String, RoleSettings>,
185
186    /// App-level settings to expose via GUC
187    #[serde(default)]
188    pub app_settings: HashMap<String, String>,
189
190    // ========================================================================
191    // Compatibility Settings
192    // ========================================================================
193    /// PostgREST compatibility mode.
194    ///
195    /// When enabled, the REST surface is also served at the root (so canonical
196    /// PostgREST paths like `/rpc/<name>` and `/<table>` work in addition to
197    /// the `/api`-prefixed paths), and RPC responses are un-wrapped to match
198    /// PostgREST's shape (bare object/scalar for non-set-returning functions,
199    /// a top-level array for set-returning ones) instead of the array-wrapped,
200    /// function-name-keyed default.
201    #[serde(default)]
202    pub compat_mode: bool,
203}
204
205impl Default for AppConfig {
206    fn default() -> Self {
207        Self {
208            db_uri: default_db_uri(),
209            db_schemas: default_db_schemas(),
210            db_anon_role: None,
211            db_pool_size: default_pool_size(),
212            db_pool_timeout: default_pool_timeout(),
213            db_tx_isolation: default_tx_isolation(),
214            db_prepared_statements: true,
215            db_extra_search_path: default_extra_search_path(),
216            db_channel: default_db_channel(),
217            db_channel_enabled: false,
218            db_pre_request: None,
219            db_max_rows: None,
220            db_aggregates_enabled: false,
221            server_host: default_host(),
222            server_port: default_port(),
223            server_cors_origins: Vec::new(),
224            max_body_size: default_max_body_size(),
225            server_unix_socket: None,
226            admin_server_port: None,
227            jwt_secret: None,
228            jwt_secret_is_base64: false,
229            jwt_aud: None,
230            jwt_role_claim_key: default_jwt_role_claim(),
231            jwt_cache_enabled: true,
232            jwt_cache_max_lifetime: default_jwt_cache_max(),
233            hasura_admin_secret: None,
234            hasura_unauthorized_role: None,
235            openapi_server_proxy_uri: None,
236            openapi_mode: OpenApiMode::FollowPrivileges,
237            log_level: default_log_level(),
238            role_settings: HashMap::new(),
239            app_settings: HashMap::new(),
240            compat_mode: false,
241        }
242    }
243}
244
245/// Say that a variable was set to something unusable, rather than silently
246/// applying the default. A rejected value and an unset one are different
247/// mistakes and the operator can only fix the one they are told about.
248fn warn_ignored(var: &str, value: &str, expected: &str) {
249    tracing::warn!("Ignoring {}={:?}: expected {}", var, value, expected);
250}
251
252/// The truthy spellings accepted for a boolean environment variable.
253fn env_bool(value: &str) -> bool {
254    matches!(
255        value.trim().to_ascii_lowercase().as_str(),
256        "true" | "1" | "yes" | "on"
257    )
258}
259
260impl AppConfig {
261    /// Load configuration from environment variables.
262    pub fn from_env() -> Self {
263        let mut config = Self::default();
264
265        if let Ok(uri) = std::env::var("PGRST_DB_URI") {
266            config.db_uri = uri;
267        }
268        if let Ok(uri) = std::env::var("DATABASE_URL") {
269            config.db_uri = uri;
270        }
271        if let Ok(schemas) = std::env::var("PGRST_DB_SCHEMAS") {
272            config.db_schemas = schemas.split(',').map(|s| s.trim().to_string()).collect();
273        }
274        if let Ok(role) = std::env::var("PGRST_DB_ANON_ROLE") {
275            config.db_anon_role = Some(role);
276        }
277        // `PGRST_DB_POOL` is PostgREST's own name; `PGRST_DB_POOL_SIZE` is the
278        // one this project's documentation has always given, including the
279        // production checklist in docs/deployment.md. Only the former was read,
280        // so anybody who followed our own instructions silently kept the
281        // default pool.
282        for var in ["PGRST_DB_POOL", "PGRST_DB_POOL_SIZE"] {
283            if let Ok(size) = std::env::var(var) {
284                match size.parse::<u32>() {
285                    Ok(n) if n > 0 => config.db_pool_size = n,
286                    _ => warn_ignored(var, &size, "a positive integer"),
287                }
288            }
289        }
290        if let Ok(secs) = std::env::var("PGRST_DB_POOL_TIMEOUT") {
291            match secs.parse::<u64>() {
292                Ok(n) if n > 0 => config.db_pool_timeout = n,
293                _ => warn_ignored("PGRST_DB_POOL_TIMEOUT", &secs, "a positive integer"),
294            }
295        }
296        if let Ok(level) = std::env::var("PGRST_DB_TX_ISOLATION") {
297            match IsolationLevel::parse_config(&level) {
298                Some(l) => config.db_tx_isolation = l,
299                None => warn_ignored(
300                    "PGRST_DB_TX_ISOLATION",
301                    &level,
302                    "one of: read committed, repeatable read, serializable",
303                ),
304            }
305        }
306        if let Ok(v) = std::env::var("PGRST_DB_EXTRA_SEARCH_PATH") {
307            config.db_extra_search_path = v
308                .split(',')
309                .map(|s| s.trim().to_string())
310                .filter(|s| !s.is_empty())
311                .collect();
312        }
313
314        if let Ok(v) = std::env::var("PGRST_DB_AGGREGATES_ENABLED") {
315            config.db_aggregates_enabled = env_bool(&v);
316        }
317        if let Ok(v) = std::env::var("PGRST_DB_PREPARED_STATEMENTS") {
318            config.db_prepared_statements = env_bool(&v);
319        }
320        if let Ok(v) = std::env::var("PGRST_DB_PRE_REQUEST") {
321            if !v.trim().is_empty() {
322                config.db_pre_request = Some(v.trim().to_string());
323            }
324        }
325        if let Ok(v) = std::env::var("PGRST_DB_CHANNEL") {
326            if !v.trim().is_empty() {
327                config.db_channel = v.trim().to_string();
328            }
329        }
330        if let Ok(v) = std::env::var("PGRST_DB_CHANNEL_ENABLED") {
331            config.db_channel_enabled = env_bool(&v);
332        }
333
334        // `PGRST_DB_MAX_ROWS` mirrors PostgREST's `db-max-rows`. Accept both.
335        for var in ["PGRST_DB_MAX_ROWS", "PGRST_MAX_ROWS"] {
336            if let Ok(max_rows) = std::env::var(var) {
337                match max_rows.parse::<i64>() {
338                    Ok(n) if n >= 0 => config.db_max_rows = Some(n),
339                    _ => warn_ignored(var, &max_rows, "a non-negative integer"),
340                }
341            }
342        }
343        if let Ok(secret) = std::env::var("PGRST_JWT_SECRET") {
344            config.jwt_secret = Some(secret);
345        }
346        if let Ok(aud) = std::env::var("PGRST_JWT_AUD") {
347            config.jwt_aud = Some(aud);
348        }
349        // Both of these reach the JWT layer already -- `JwtConfig` is built
350        // from them in every binary. Only the environment read was missing, so
351        // a base64 secret was verified against its own text and a role claim
352        // outside `role` was never found.
353        if let Ok(v) = std::env::var("PGRST_JWT_SECRET_IS_BASE64") {
354            config.jwt_secret_is_base64 = env_bool(&v);
355        }
356        if let Ok(key) = std::env::var("PGRST_JWT_ROLE_CLAIM_KEY") {
357            if !key.trim().is_empty() {
358                config.jwt_role_claim_key = key;
359            }
360        }
361        if let Ok(v) = std::env::var("PGRST_JWT_CACHE_ENABLED") {
362            config.jwt_cache_enabled = env_bool(&v);
363        }
364        // Zero is meaningful here -- it turns the cache off, as in PostgREST --
365        // so unlike the other durations it is not rejected.
366        if let Ok(v) = std::env::var("PGRST_JWT_CACHE_MAX_LIFETIME") {
367            match v.parse::<u64>() {
368                Ok(n) => config.jwt_cache_max_lifetime = n,
369                Err(_) => warn_ignored(
370                    "PGRST_JWT_CACHE_MAX_LIFETIME",
371                    &v,
372                    "a non-negative integer number of seconds",
373                ),
374            }
375        }
376        // Hasura's own spelling is accepted beside ours, because a deployment
377        // migrating from it has these in a compose file already and the point
378        // of the mode is that the client does not have to be rewritten. The
379        // `PGRST_` name wins where both are set, on the grounds that it is the
380        // more specific instruction to this server.
381        for var in ["HASURA_GRAPHQL_ADMIN_SECRET", "PGRST_HASURA_ADMIN_SECRET"] {
382            if let Ok(secret) = std::env::var(var) {
383                if !secret.is_empty() {
384                    config.hasura_admin_secret = Some(secret);
385                }
386            }
387        }
388        for var in [
389            "HASURA_GRAPHQL_UNAUTHORIZED_ROLE",
390            "PGRST_HASURA_UNAUTHORIZED_ROLE",
391        ] {
392            if let Ok(role) = std::env::var(var) {
393                if !role.is_empty() {
394                    config.hasura_unauthorized_role = Some(role);
395                }
396            }
397        }
398        if let Ok(host) = std::env::var("PGRST_SERVER_HOST") {
399            config.server_host = host;
400        }
401        if let Ok(port) = std::env::var("PGRST_SERVER_PORT") {
402            if let Ok(p) = port.parse() {
403                config.server_port = p;
404            }
405        }
406        if let Ok(port) = std::env::var("PORT") {
407            if let Ok(p) = port.parse() {
408                config.server_port = p;
409            }
410        }
411        // A bare `*` is the documented way to spell "any origin", and is the
412        // default. An empty list means the same thing, so a value of only
413        // separators does not accidentally lock everybody out.
414        if let Ok(origins) = std::env::var("PGRST_SERVER_CORS_ORIGINS") {
415            config.server_cors_origins = if origins.trim() == "*" {
416                Vec::new()
417            } else {
418                origins
419                    .split(',')
420                    .map(|s| s.trim().to_string())
421                    .filter(|s| !s.is_empty())
422                    .collect()
423            };
424        }
425        if let Ok(bytes) = std::env::var("PGRST_MAX_BODY_SIZE") {
426            match bytes.parse::<usize>() {
427                Ok(n) if n > 0 => config.max_body_size = n,
428                _ => warn_ignored("PGRST_MAX_BODY_SIZE", &bytes, "a positive integer"),
429            }
430        }
431        if let Ok(level) = std::env::var("PGRST_LOG_LEVEL") {
432            match LogLevel::parse_config(&level) {
433                Some(l) => config.log_level = l,
434                None => warn_ignored(
435                    "PGRST_LOG_LEVEL",
436                    &level,
437                    "one of: crit, error, warn, info, debug",
438                ),
439            }
440        }
441        if let Ok(path) = std::env::var("PGRST_SERVER_UNIX_SOCKET") {
442            if !path.trim().is_empty() {
443                config.server_unix_socket = Some(path.trim().to_string());
444            }
445        }
446        if let Ok(port) = std::env::var("PGRST_ADMIN_SERVER_PORT") {
447            match port.parse::<u16>() {
448                Ok(p) if p > 0 => config.admin_server_port = Some(p),
449                _ => warn_ignored("PGRST_ADMIN_SERVER_PORT", &port, "a port number"),
450            }
451        }
452        if let Ok(uri) = std::env::var("PGRST_OPENAPI_SERVER_PROXY_URI") {
453            if !uri.trim().is_empty() {
454                config.openapi_server_proxy_uri = Some(uri.trim().to_string());
455            }
456        }
457        if let Ok(mode) = std::env::var("PGRST_OPENAPI_MODE") {
458            match OpenApiMode::parse_config(&mode) {
459                Some(m) => config.openapi_mode = m,
460                None => warn_ignored(
461                    "PGRST_OPENAPI_MODE",
462                    &mode,
463                    "one of: disabled, follow-privileges, ignore-privileges",
464                ),
465            }
466        }
467
468        // `PGRST_ROLE_SETTINGS` is JSON rather than a per-role variable,
469        // because a PostgreSQL role name may hold characters an environment
470        // variable name cannot.
471        if let Ok(raw) = std::env::var("PGRST_ROLE_SETTINGS") {
472            match serde_json::from_str::<HashMap<String, RoleSettings>>(&raw) {
473                Ok(settings) => config.role_settings = settings,
474                Err(e) => warn_ignored(
475                    "PGRST_ROLE_SETTINGS",
476                    &raw,
477                    &format!("a JSON object of role to settings ({e})"),
478                ),
479            }
480        }
481
482        // `PGRST_APP_SETTINGS_<NAME>` becomes `app.settings.<name>`, which is
483        // where PostgREST puts it and where a policy expects to read it back.
484        for (key, value) in std::env::vars() {
485            if let Some(name) = key.strip_prefix("PGRST_APP_SETTINGS_") {
486                if !name.is_empty() {
487                    config.app_settings.insert(name.to_ascii_lowercase(), value);
488                }
489            }
490        }
491        // Accept either the PGRST_-prefixed name (for parity with other options)
492        // or a POSTRUST_-prefixed alias.
493        for var in ["PGRST_COMPAT_MODE", "POSTRUST_COMPAT_MODE"] {
494            if let Ok(v) = std::env::var(var) {
495                config.compat_mode = env_bool(&v);
496            }
497        }
498
499        config
500    }
501
502    /// Get the default schema (first in the list).
503    pub fn default_schema(&self) -> &str {
504        self.db_schemas
505            .first()
506            .map(|s| s.as_str())
507            .unwrap_or("public")
508    }
509}
510
511// Serialized as the spelling configuration uses, not as the Rust variant name,
512// so that what this writes is what `parse_config` reads back. Deriving
513// `Serialize` here instead emits `"ReadCommitted"`, which then fails to
514// deserialize -- and `Serializable` happens to survive the round trip, which
515// makes the breakage look intermittent rather than total.
516impl Serialize for IsolationLevel {
517    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
518        serializer.serialize_str(self.as_config_str())
519    }
520}
521
522impl<'de> Deserialize<'de> for IsolationLevel {
523    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
524        let raw = String::deserialize(deserializer)?;
525        Self::parse_config(&raw).ok_or_else(|| {
526            serde::de::Error::custom(format!(
527                "unknown isolation level {raw:?}: expected one of \
528                 read committed, repeatable read, serializable"
529            ))
530        })
531    }
532}
533
534impl Serialize for OpenApiMode {
535    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
536        serializer.serialize_str(self.as_config_str())
537    }
538}
539
540impl<'de> Deserialize<'de> for OpenApiMode {
541    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
542        let raw = String::deserialize(deserializer)?;
543        Self::parse_config(&raw).ok_or_else(|| {
544            serde::de::Error::custom(format!(
545                "unknown OpenAPI mode {raw:?}: expected one of \
546                 disabled, follow-privileges, ignore-privileges"
547            ))
548        })
549    }
550}
551
552impl Serialize for LogLevel {
553    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
554        serializer.serialize_str(self.as_config_str())
555    }
556}
557
558impl<'de> Deserialize<'de> for LogLevel {
559    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
560        let raw = String::deserialize(deserializer)?;
561        Self::parse_config(&raw).ok_or_else(|| {
562            serde::de::Error::custom(format!(
563                "unknown log level {raw:?}: expected one of \
564                 crit, error, warn, info, debug"
565            ))
566        })
567    }
568}
569
570/// Per-role settings.
571#[derive(Clone, Debug, Serialize, Deserialize)]
572pub struct RoleSettings {
573    /// Isolation level for this role
574    pub isolation_level: Option<IsolationLevel>,
575    /// Statement timeout in milliseconds
576    pub statement_timeout: Option<u64>,
577}
578
579/// Transaction isolation levels.
580///
581/// Deserialized from the spelling the documentation uses (`"serializable"`,
582/// `"read committed"`), not from the Rust variant name, so a `role_settings`
583/// entry reads the same as the environment variable.
584#[derive(Clone, Debug, PartialEq, Eq)]
585pub enum IsolationLevel {
586    /// PostgreSQL's default. A statement sees rows committed before it began.
587    ReadCommitted,
588    /// Every statement in the transaction sees the same snapshot.
589    RepeatableRead,
590    /// As `RepeatableRead`, and concurrent transactions are additionally
591    /// guaranteed to be equivalent to some serial order. May abort with a
592    /// serialization failure, which the client is expected to retry.
593    Serializable,
594}
595
596impl IsolationLevel {
597    /// The spelling PostgreSQL's `SET TRANSACTION ISOLATION LEVEL` expects.
598    pub fn to_sql(&self) -> &'static str {
599        match self {
600            Self::ReadCommitted => "READ COMMITTED",
601            Self::RepeatableRead => "REPEATABLE READ",
602            Self::Serializable => "SERIALIZABLE",
603        }
604    }
605
606    /// Parse the spelling used in configuration.
607    ///
608    /// Case-insensitive, and a hyphen, underscore or space separates the words
609    /// equally -- the documentation writes `read committed` and an environment
610    /// file is as likely to carry `read-committed`.
611    pub fn parse_config(s: &str) -> Option<Self> {
612        match s
613            .trim()
614            .to_ascii_lowercase()
615            .replace(['-', '_'], " ")
616            .as_str()
617        {
618            "read committed" => Some(Self::ReadCommitted),
619            "repeatable read" => Some(Self::RepeatableRead),
620            "serializable" => Some(Self::Serializable),
621            _ => None,
622        }
623    }
624
625    /// The inverse of [`parse_config`](Self::parse_config).
626    pub fn as_config_str(&self) -> &'static str {
627        match self {
628            Self::ReadCommitted => "read committed",
629            Self::RepeatableRead => "repeatable read",
630            Self::Serializable => "serializable",
631        }
632    }
633}
634
635/// OpenAPI generation mode.
636///
637/// PostgREST also has a `security-definer` mode, where the specification comes
638/// from a user-supplied `SECURITY DEFINER` function. There is no configuration
639/// here for naming that function, so the variant is absent rather than present
640/// and behaving as one of the others.
641#[derive(Clone, Debug, PartialEq, Eq)]
642pub enum OpenApiMode {
643    /// No OpenAPI document is served.
644    Disabled,
645    /// The document describes only what the requesting role may reach, so two
646    /// roles asking are answered differently.
647    FollowPrivileges,
648    /// The document describes the whole exposed schema, whoever is asking.
649    IgnorePrivileges,
650}
651
652impl OpenApiMode {
653    /// Parse the spelling used in configuration. Case-insensitive, and a
654    /// hyphen or an underscore separates the words equally.
655    pub fn parse_config(s: &str) -> Option<Self> {
656        match s
657            .trim()
658            .to_ascii_lowercase()
659            .replace(['-', '_'], " ")
660            .as_str()
661        {
662            "disabled" => Some(Self::Disabled),
663            "follow privileges" => Some(Self::FollowPrivileges),
664            "ignore privileges" => Some(Self::IgnorePrivileges),
665            _ => None,
666        }
667    }
668
669    /// The inverse of [`parse_config`](Self::parse_config).
670    pub fn as_config_str(&self) -> &'static str {
671        match self {
672            Self::Disabled => "disabled",
673            Self::FollowPrivileges => "follow-privileges",
674            Self::IgnorePrivileges => "ignore-privileges",
675        }
676    }
677}
678
679/// Log levels.
680#[derive(Clone, Debug, PartialEq, Eq)]
681pub enum LogLevel {
682    /// Only failures that stop the server. Mapped onto `ERROR`, which is the
683    /// least severe level `tracing` offers.
684    Crit,
685    /// Requests that failed, and faults that did not stop the server.
686    Error,
687    /// Configuration that was ignored, and conditions worth noticing.
688    Warn,
689    /// Start-up, schema reloads, and one line per request.
690    Info,
691    /// The generated SQL and the decisions behind it.
692    Debug,
693}
694
695impl LogLevel {
696    /// The `tracing` level this maps onto.
697    ///
698    /// `Crit` and `Error` both become `ERROR`: PostgREST distinguishes them
699    /// and `tracing` has nothing more severe to map `Crit` onto.
700    pub fn to_tracing(&self) -> tracing::Level {
701        match self {
702            Self::Crit | Self::Error => tracing::Level::ERROR,
703            Self::Warn => tracing::Level::WARN,
704            Self::Info => tracing::Level::INFO,
705            Self::Debug => tracing::Level::DEBUG,
706        }
707    }
708
709    /// Parse the spelling used in configuration. Case-insensitive.
710    pub fn parse_config(s: &str) -> Option<Self> {
711        match s.trim().to_ascii_lowercase().as_str() {
712            "crit" | "critical" => Some(Self::Crit),
713            "error" => Some(Self::Error),
714            "warn" | "warning" => Some(Self::Warn),
715            "info" => Some(Self::Info),
716            "debug" => Some(Self::Debug),
717            _ => None,
718        }
719    }
720
721    /// The inverse of [`parse_config`](Self::parse_config).
722    pub fn as_config_str(&self) -> &'static str {
723        match self {
724            Self::Crit => "crit",
725            Self::Error => "error",
726            Self::Warn => "warn",
727            Self::Info => "info",
728            Self::Debug => "debug",
729        }
730    }
731}
732
733// Default value functions
734fn default_db_uri() -> String {
735    "postgresql://localhost/postgres".to_string()
736}
737
738fn default_db_schemas() -> Vec<String> {
739    vec!["public".to_string()]
740}
741
742fn default_pool_size() -> u32 {
743    10
744}
745
746fn default_pool_timeout() -> u64 {
747    10
748}
749
750fn default_tx_isolation() -> IsolationLevel {
751    IsolationLevel::ReadCommitted
752}
753
754fn default_max_body_size() -> usize {
755    10 * 1024 * 1024
756}
757
758fn default_extra_search_path() -> Vec<String> {
759    vec!["public".to_string()]
760}
761
762fn default_db_channel() -> String {
763    "pgrst".to_string()
764}
765
766fn default_host() -> String {
767    "127.0.0.1".to_string()
768}
769
770fn default_port() -> u16 {
771    3000
772}
773
774fn default_jwt_role_claim() -> String {
775    "role".to_string()
776}
777
778fn default_jwt_cache_max() -> u64 {
779    3600
780}
781
782fn default_openapi_mode() -> OpenApiMode {
783    OpenApiMode::FollowPrivileges
784}
785
786// `info`, not `error`. The documentation has always said `info`, and the
787// server's own fallback filter is `postrust=info`, so `error` was a third
788// answer that agreed with neither -- harmless only for as long as nothing
789// read the field.
790fn default_log_level() -> LogLevel {
791    LogLevel::Info
792}
793
794fn default_true() -> bool {
795    true
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    #[test]
803    fn test_default_config() {
804        let config = AppConfig::default();
805        assert_eq!(config.server_port, 3000);
806        assert_eq!(config.db_pool_size, 10);
807        assert!(config.db_prepared_statements);
808    }
809
810    #[test]
811    fn test_default_schema() {
812        let mut config = AppConfig::default();
813        assert_eq!(config.default_schema(), "public");
814
815        config.db_schemas = vec!["api".to_string(), "public".to_string()];
816        assert_eq!(config.default_schema(), "api");
817    }
818
819    #[test]
820    fn test_isolation_level_sql() {
821        assert_eq!(IsolationLevel::ReadCommitted.to_sql(), "READ COMMITTED");
822        assert_eq!(IsolationLevel::Serializable.to_sql(), "SERIALIZABLE");
823    }
824
825    #[test]
826    fn isolation_level_accepts_the_documented_spellings() {
827        // The documentation writes `read committed`; an environment file is as
828        // likely to carry either separator, or capitals.
829        for s in [
830            "read committed",
831            "READ COMMITTED",
832            "read-committed",
833            " Read_Committed ",
834        ] {
835            assert_eq!(
836                IsolationLevel::parse_config(s),
837                Some(IsolationLevel::ReadCommitted),
838                "{s:?}"
839            );
840        }
841        assert_eq!(
842            IsolationLevel::parse_config("serializable"),
843            Some(IsolationLevel::Serializable)
844        );
845        assert_eq!(
846            IsolationLevel::parse_config("repeatable read"),
847            Some(IsolationLevel::RepeatableRead)
848        );
849        // Not a level. Rejected rather than resolved to a default, so the
850        // caller can say so.
851        assert_eq!(IsolationLevel::parse_config("dirty read"), None);
852        assert_eq!(IsolationLevel::parse_config(""), None);
853    }
854
855    #[test]
856    fn log_level_accepts_the_documented_spellings() {
857        assert_eq!(LogLevel::parse_config("INFO"), Some(LogLevel::Info));
858        assert_eq!(LogLevel::parse_config("warning"), Some(LogLevel::Warn));
859        assert_eq!(LogLevel::parse_config("crit"), Some(LogLevel::Crit));
860        // `trace` was listed in the documentation and is not a level this
861        // server has. It is rejected rather than silently read as `debug`.
862        assert_eq!(LogLevel::parse_config("trace"), None);
863    }
864
865    #[test]
866    fn default_log_level_matches_the_documented_one() {
867        // The struct default disagreed with both the documentation and the
868        // server's own fallback filter for as long as nothing read it.
869        assert_eq!(AppConfig::default().log_level, LogLevel::Info);
870    }
871
872    #[test]
873    fn env_bool_accepts_the_usual_spellings() {
874        for s in ["true", "TRUE", "1", "yes", "on", " On "] {
875            assert!(env_bool(s), "{s:?}");
876        }
877        for s in ["false", "0", "no", "off", "", "maybe"] {
878            assert!(!env_bool(s), "{s:?}");
879        }
880    }
881
882    // `from_env` reads process-global state, so these run one at a time.
883    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
884
885    /// Set the given variables, load, then remove them again.
886    fn with_env(vars: &[(&str, &str)]) -> AppConfig {
887        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
888        for (k, v) in vars {
889            std::env::set_var(k, v);
890        }
891        let config = AppConfig::from_env();
892        for (k, _) in vars {
893            std::env::remove_var(k);
894        }
895        config
896    }
897
898    #[test]
899    fn documented_variables_are_read() {
900        // Every one of these was documented and read by nothing. The features
901        // behind them already worked -- only the environment read was missing.
902        let config = with_env(&[
903            ("PGRST_DB_POOL_SIZE", "42"),
904            ("PGRST_DB_POOL_TIMEOUT", "7"),
905            ("PGRST_DB_TX_ISOLATION", "serializable"),
906            ("PGRST_JWT_SECRET_IS_BASE64", "true"),
907            ("PGRST_JWT_ROLE_CLAIM_KEY", "user.role"),
908            ("PGRST_LOG_LEVEL", "warn"),
909            ("PGRST_MAX_BODY_SIZE", "1024"),
910            (
911                "PGRST_SERVER_CORS_ORIGINS",
912                "https://a.example, https://b.example",
913            ),
914        ]);
915
916        assert_eq!(config.db_pool_size, 42);
917        assert_eq!(config.db_pool_timeout, 7);
918        assert_eq!(config.db_tx_isolation, IsolationLevel::Serializable);
919        assert!(config.jwt_secret_is_base64);
920        assert_eq!(config.jwt_role_claim_key, "user.role");
921        assert_eq!(config.log_level, LogLevel::Warn);
922        assert_eq!(config.max_body_size, 1024);
923        assert_eq!(
924            config.server_cors_origins,
925            vec![
926                "https://a.example".to_string(),
927                "https://b.example".to_string()
928            ]
929        );
930    }
931
932    #[test]
933    fn pool_size_accepts_postgrests_own_name() {
934        assert_eq!(with_env(&[("PGRST_DB_POOL", "5")]).db_pool_size, 5);
935    }
936
937    #[test]
938    fn a_star_origin_means_any_origin() {
939        // Empty is how "any" is carried, so `*` must not become a literal
940        // origin that matches nothing.
941        assert!(with_env(&[("PGRST_SERVER_CORS_ORIGINS", "*")])
942            .server_cors_origins
943            .is_empty());
944        assert!(with_env(&[("PGRST_SERVER_CORS_ORIGINS", " , ")])
945            .server_cors_origins
946            .is_empty());
947    }
948
949    #[test]
950    fn an_unusable_value_leaves_the_default() {
951        let config = with_env(&[
952            ("PGRST_DB_POOL_SIZE", "lots"),
953            ("PGRST_DB_POOL_TIMEOUT", "0"),
954            ("PGRST_MAX_BODY_SIZE", "-1"),
955            ("PGRST_DB_TX_ISOLATION", "dirty read"),
956            ("PGRST_LOG_LEVEL", "shout"),
957        ]);
958
959        let d = AppConfig::default();
960        assert_eq!(config.db_pool_size, d.db_pool_size);
961        assert_eq!(config.db_pool_timeout, d.db_pool_timeout);
962        assert_eq!(config.max_body_size, d.max_body_size);
963        assert_eq!(config.db_tx_isolation, d.db_tx_isolation);
964        assert_eq!(config.log_level, d.log_level);
965    }
966
967    #[test]
968    fn the_formerly_dead_fields_are_read() {
969        // Every one of these was a public field that nothing set and nothing
970        // consumed.
971        let config = with_env(&[
972            ("PGRST_DB_PREPARED_STATEMENTS", "false"),
973            ("PGRST_DB_PRE_REQUEST", "auth.check"),
974            ("PGRST_DB_CHANNEL", "my_channel"),
975            ("PGRST_DB_CHANNEL_ENABLED", "yes"),
976            ("PGRST_JWT_CACHE_ENABLED", "false"),
977            ("PGRST_JWT_CACHE_MAX_LIFETIME", "60"),
978            ("PGRST_SERVER_UNIX_SOCKET", "/tmp/postrust.sock"),
979            ("PGRST_ADMIN_SERVER_PORT", "3001"),
980            ("PGRST_OPENAPI_SERVER_PROXY_URI", "https://api.example.com"),
981            ("PGRST_OPENAPI_MODE", "ignore-privileges"),
982        ]);
983
984        assert!(!config.db_prepared_statements);
985        assert_eq!(config.db_pre_request.as_deref(), Some("auth.check"));
986        assert_eq!(config.db_channel, "my_channel");
987        assert!(config.db_channel_enabled);
988        assert!(!config.jwt_cache_enabled);
989        assert_eq!(config.jwt_cache_max_lifetime, 60);
990        assert_eq!(
991            config.server_unix_socket.as_deref(),
992            Some("/tmp/postrust.sock")
993        );
994        assert_eq!(config.admin_server_port, Some(3001));
995        assert_eq!(
996            config.openapi_server_proxy_uri.as_deref(),
997            Some("https://api.example.com")
998        );
999        assert_eq!(config.openapi_mode, OpenApiMode::IgnorePrivileges);
1000    }
1001
1002    #[test]
1003    fn a_zero_jwt_cache_lifetime_is_kept_because_it_turns_the_cache_off() {
1004        // Unlike the other durations, 0 is a meaningful value here and must
1005        // not be rejected as unusable.
1006        assert_eq!(
1007            with_env(&[("PGRST_JWT_CACHE_MAX_LIFETIME", "0")]).jwt_cache_max_lifetime,
1008            0
1009        );
1010    }
1011
1012    #[test]
1013    fn app_settings_come_from_a_prefix() {
1014        let config = with_env(&[
1015            ("PGRST_APP_SETTINGS_JWT_LIFETIME", "3600"),
1016            ("PGRST_APP_SETTINGS_TenantName", "acme"),
1017        ]);
1018        // Lower-cased, because that is the name a policy reads back with
1019        // `current_setting('app.settings.<name>')`.
1020        assert_eq!(
1021            config.app_settings.get("jwt_lifetime").map(String::as_str),
1022            Some("3600")
1023        );
1024        assert_eq!(
1025            config.app_settings.get("tenantname").map(String::as_str),
1026            Some("acme")
1027        );
1028    }
1029
1030    #[test]
1031    fn role_settings_parse_from_json_using_the_documented_spellings() {
1032        let config = with_env(&[(
1033            "PGRST_ROLE_SETTINGS",
1034            r#"{"web_user":{"isolation_level":"serializable","statement_timeout":5000}}"#,
1035        )]);
1036        let web_user = config.role_settings.get("web_user").expect("role present");
1037        assert_eq!(web_user.isolation_level, Some(IsolationLevel::Serializable));
1038        assert_eq!(web_user.statement_timeout, Some(5000));
1039    }
1040
1041    #[test]
1042    fn unparseable_role_settings_leave_the_map_empty() {
1043        assert!(with_env(&[("PGRST_ROLE_SETTINGS", "{not json")])
1044            .role_settings
1045            .is_empty());
1046        // A valid JSON object naming a level that does not exist is refused
1047        // as a whole rather than half-applied.
1048        assert!(with_env(&[(
1049            "PGRST_ROLE_SETTINGS",
1050            r#"{"r":{"isolation_level":"dirty"}}"#
1051        )])
1052        .role_settings
1053        .is_empty());
1054    }
1055
1056    #[test]
1057    fn openapi_mode_accepts_the_documented_spellings() {
1058        assert_eq!(
1059            OpenApiMode::parse_config("follow-privileges"),
1060            Some(OpenApiMode::FollowPrivileges)
1061        );
1062        assert_eq!(
1063            OpenApiMode::parse_config("IGNORE_PRIVILEGES"),
1064            Some(OpenApiMode::IgnorePrivileges)
1065        );
1066        assert_eq!(
1067            OpenApiMode::parse_config("disabled"),
1068            Some(OpenApiMode::Disabled)
1069        );
1070        // PostgREST's fourth mode needs a function this server has no
1071        // configuration for, so it is not accepted rather than aliased.
1072        assert_eq!(OpenApiMode::parse_config("security-definer"), None);
1073    }
1074
1075    #[test]
1076    fn the_config_enums_round_trip_through_json() {
1077        // Deriving `Serialize` emits the Rust variant name while the
1078        // hand-written `Deserialize` reads the configuration spelling, so a
1079        // value this type writes could not be read back. `Serializable`
1080        // survived by coincidence -- its variant name lowercases to the
1081        // accepted spelling -- which made the breakage look intermittent.
1082        for level in [
1083            IsolationLevel::ReadCommitted,
1084            IsolationLevel::RepeatableRead,
1085            IsolationLevel::Serializable,
1086        ] {
1087            let json = serde_json::to_string(&level).unwrap();
1088            let back: IsolationLevel = serde_json::from_str(&json).unwrap();
1089            assert_eq!(back, level, "{json}");
1090        }
1091
1092        for mode in [
1093            OpenApiMode::Disabled,
1094            OpenApiMode::FollowPrivileges,
1095            OpenApiMode::IgnorePrivileges,
1096        ] {
1097            let json = serde_json::to_string(&mode).unwrap();
1098            assert_eq!(serde_json::from_str::<OpenApiMode>(&json).unwrap(), mode);
1099        }
1100
1101        for level in [
1102            LogLevel::Crit,
1103            LogLevel::Error,
1104            LogLevel::Warn,
1105            LogLevel::Info,
1106            LogLevel::Debug,
1107        ] {
1108            let json = serde_json::to_string(&level).unwrap();
1109            assert_eq!(serde_json::from_str::<LogLevel>(&json).unwrap(), level);
1110        }
1111    }
1112
1113    #[test]
1114    fn a_whole_config_round_trips_through_json() {
1115        // The struct derives both, which invites persisting it.
1116        let mut config = AppConfig::default();
1117        config.role_settings.insert(
1118            "web_user".to_string(),
1119            RoleSettings {
1120                isolation_level: Some(IsolationLevel::RepeatableRead),
1121                statement_timeout: Some(5000),
1122            },
1123        );
1124
1125        let json = serde_json::to_string(&config).unwrap();
1126        let back: AppConfig = serde_json::from_str(&json).expect("config should round trip");
1127
1128        assert_eq!(back.db_tx_isolation, config.db_tx_isolation);
1129        assert_eq!(back.log_level, config.log_level);
1130        assert_eq!(back.openapi_mode, config.openapi_mode);
1131        assert_eq!(
1132            back.role_settings["web_user"].isolation_level,
1133            Some(IsolationLevel::RepeatableRead)
1134        );
1135    }
1136
1137    #[test]
1138    fn the_serialized_spelling_is_the_configuration_spelling() {
1139        // Not the Rust variant name: what is written should be what an
1140        // operator could paste into the environment.
1141        assert_eq!(
1142            serde_json::to_string(&IsolationLevel::ReadCommitted).unwrap(),
1143            "\"read committed\""
1144        );
1145        assert_eq!(
1146            serde_json::to_string(&OpenApiMode::FollowPrivileges).unwrap(),
1147            "\"follow-privileges\""
1148        );
1149        assert_eq!(serde_json::to_string(&LogLevel::Warn).unwrap(), "\"warn\"");
1150    }
1151
1152    #[test]
1153    fn an_empty_role_claim_key_is_not_accepted() {
1154        // An empty key would look up nothing and every request would fall to
1155        // the anonymous role.
1156        assert_eq!(
1157            with_env(&[("PGRST_JWT_ROLE_CLAIM_KEY", "  ")]).jwt_role_claim_key,
1158            AppConfig::default().jwt_role_claim_key
1159        );
1160    }
1161}