diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 9ed7a1c7..730b3915 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -228,6 +228,54 @@ fn database_connect_options_from_config( opts }; + let options = match (config.ssl_ca.as_deref(), config.ssl_ca_file.as_deref()) { + (None, None) => options, + (Some(pem), None) => options.ssl_root_cert_from_pem(pem.as_bytes().to_owned()), + (None, Some(path)) => options.ssl_root_cert(path), + (Some(_), Some(_)) => { + anyhow::bail!("invalid database configuration: both `ssl_ca` and `ssl_ca_file` are set") + } + }; + + let options = match ( + config.ssl_certificate.as_deref(), + config.ssl_certificate_file.as_deref(), + ) { + (None, None) => options, + (Some(pem), None) => options.ssl_client_cert_from_pem(pem.as_bytes()), + (None, Some(path)) => options.ssl_client_cert(path), + (Some(_), Some(_)) => { + anyhow::bail!("invalid database configuration: both `ssl_certificate` and `ssl_certificate_file` are set") + } + }; + + let options = match (config.ssl_key.as_deref(), config.ssl_key_file.as_deref()) { + (None, None) => options, + (Some(pem), None) => options.ssl_client_key_from_pem(pem.as_bytes()), + (None, Some(path)) => options.ssl_client_key(path), + (Some(_), Some(_)) => { + anyhow::bail!( + "invalid database configuration: both `ssl_key` and `ssl_key_file` are set" + ) + } + }; + + let options = match &config.ssl_mode { + Some(ssl_mode) => { + let ssl_mode = match ssl_mode { + mas_config::PgSslMode::Disable => sqlx::postgres::PgSslMode::Disable, + mas_config::PgSslMode::Allow => sqlx::postgres::PgSslMode::Allow, + mas_config::PgSslMode::Prefer => sqlx::postgres::PgSslMode::Prefer, + mas_config::PgSslMode::Require => sqlx::postgres::PgSslMode::Require, + mas_config::PgSslMode::VerifyCa => sqlx::postgres::PgSslMode::VerifyCa, + mas_config::PgSslMode::VerifyFull => sqlx::postgres::PgSslMode::VerifyFull, + }; + + options.ssl_mode(ssl_mode) + } + None => options, + }; + let options = options .log_statements(LevelFilter::Debug) .log_slow_statements(LevelFilter::Warn, Duration::from_millis(100)); diff --git a/crates/config/src/sections/database.rs b/crates/config/src/sections/database.rs index 4b171131..7dde01a9 100644 --- a/crates/config/src/sections/database.rs +++ b/crates/config/src/sections/database.rs @@ -55,6 +55,13 @@ impl Default for DatabaseConfig { username: None, password: None, database: None, + ssl_mode: None, + ssl_ca: None, + ssl_ca_file: None, + ssl_certificate: None, + ssl_certificate_file: None, + ssl_key: None, + ssl_key_file: None, max_connections: default_max_connections(), min_connections: Default::default(), connect_timeout: default_connect_timeout(), @@ -64,6 +71,34 @@ impl Default for DatabaseConfig { } } +/// Options for controlling the level of protection provided for PostgreSQL SSL +/// connections. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum PgSslMode { + /// Only try a non-SSL connection. + Disable, + + /// First try a non-SSL connection; if that fails, try an SSL connection. + Allow, + + /// First try an SSL connection; if that fails, try a non-SSL connection. + Prefer, + + /// Only try an SSL connection. If a root CA file is present, verify the + /// connection in the same way as if `VerifyCa` was specified. + Require, + + /// Only try an SSL connection, and verify that the server certificate is + /// issued by a trusted certificate authority (CA). + VerifyCa, + + /// Only try an SSL connection; verify that the server certificate is issued + /// by a trusted CA and that the requested server host name matches that + /// in the certificate. + VerifyFull, +} + /// Database connection configuration #[serde_as] #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -115,6 +150,50 @@ pub struct DatabaseConfig { #[serde(skip_serializing_if = "Option::is_none")] pub database: Option, + /// How to handle SSL connections + #[serde(skip_serializing_if = "Option::is_none")] + pub ssl_mode: Option, + + /// The PEM-encoded root certificate for SSL connections + /// + /// This must not be specified if the `ssl_ca_file` option is specified. + #[serde(skip_serializing_if = "Option::is_none")] + pub ssl_ca: Option, + + /// Path to the root certificate for SSL connections + /// + /// This must not be specified if the `ssl_ca` option is specified. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] + pub ssl_ca_file: Option, + + /// The PEM-encoded client certificate for SSL connections + /// + /// This must not be specified if the `ssl_certificate_file` option is + /// specified. + #[serde(skip_serializing_if = "Option::is_none")] + pub ssl_certificate: Option, + + /// Path to the client certificate for SSL connections + /// + /// This must not be specified if the `ssl_certificate` option is specified. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] + pub ssl_certificate_file: Option, + + /// The PEM-encoded client key for SSL connections + /// + /// This must not be specified if the `ssl_key_file` option is specified. + #[serde(skip_serializing_if = "Option::is_none")] + pub ssl_key: Option, + + /// Path to the client key for SSL connections + /// + /// This must not be specified if the `ssl_key` option is specified. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] + pub ssl_key_file: Option, + /// Set the maximum number of connections the pool should maintain #[serde(default = "default_max_connections")] pub max_connections: NonZeroU32, @@ -153,6 +232,12 @@ impl ConfigurationSection for DatabaseConfig { fn validate(&self, figment: &figment::Figment) -> Result<(), figment::error::Error> { let metadata = figment.find_metadata(Self::PATH.unwrap()); + let annotate = |mut error: figment::Error| { + error.metadata = metadata.cloned(); + error.profile = Some(figment::Profile::Default); + error.path = vec![Self::PATH.unwrap().to_owned()]; + Err(error) + }; // Check that the user did not specify both `uri` and the split options at the // same time @@ -164,19 +249,42 @@ impl ConfigurationSection for DatabaseConfig { || self.database.is_some(); if self.uri.is_some() && has_split_options { - let mut error = figment::error::Error::from( + return annotate(figment::error::Error::from( "uri must not be specified if host, port, socket, username, password, or database are specified".to_owned(), - ); - error.metadata = metadata.cloned(); - error.profile = Some(figment::Profile::Default); - error.path = vec![Self::PATH.unwrap().to_owned(), "uri".to_owned()]; - return Err(error); + )); + } + + if self.ssl_ca.is_some() && self.ssl_ca_file.is_some() { + return annotate(figment::error::Error::from( + "ssl_ca must not be specified if ssl_ca_file is specified".to_owned(), + )); + } + + if self.ssl_certificate.is_some() && self.ssl_certificate_file.is_some() { + return annotate(figment::error::Error::from( + "ssl_certificate must not be specified if ssl_certificate_file is specified" + .to_owned(), + )); + } + + if self.ssl_key.is_some() && self.ssl_key_file.is_some() { + return annotate(figment::error::Error::from( + "ssl_key must not be specified if ssl_key_file is specified".to_owned(), + )); + } + + if (self.ssl_key.is_some() || self.ssl_key_file.is_some()) + ^ (self.ssl_certificate.is_some() || self.ssl_certificate_file.is_some()) + { + return annotate(figment::error::Error::from( + "both a ssl_certificate and a ssl_key must be set at the same time or none of them" + .to_owned(), + )); } Ok(()) } } - #[cfg(test)] mod tests { use figment::{ diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index 91074ad4..2104f7a6 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -35,7 +35,7 @@ pub use self::{ branding::BrandingConfig, captcha::{CaptchaConfig, CaptchaServiceKind}, clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig}, - database::DatabaseConfig, + database::{DatabaseConfig, PgSslMode}, email::{EmailConfig, EmailSmtpMode, EmailTransportKind}, experimental::ExperimentalConfig, http::{ diff --git a/docs/config.schema.json b/docs/config.schema.json index 85f73be3..07126ddf 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1003,6 +1003,38 @@ "description": "The database name\n\nThis must not be specified if `uri` is specified.", "type": "string" }, + "ssl_mode": { + "description": "How to handle SSL connections", + "allOf": [ + { + "$ref": "#/definitions/PgSslMode" + } + ] + }, + "ssl_ca": { + "description": "The PEM-encoded root certificate for SSL connections\n\nThis must not be specified if the `ssl_ca_file` option is specified.", + "type": "string" + }, + "ssl_ca_file": { + "description": "Path to the root certificate for SSL connections\n\nThis must not be specified if the `ssl_ca` option is specified.", + "type": "string" + }, + "ssl_certificate": { + "description": "The PEM-encoded client certificate for SSL connections\n\nThis must not be specified if the `ssl_certificate_file` option is specified.", + "type": "string" + }, + "ssl_certificate_file": { + "description": "Path to the client certificate for SSL connections\n\nThis must not be specified if the `ssl_certificate` option is specified.", + "type": "string" + }, + "ssl_key": { + "description": "The PEM-encoded client key for SSL connections\n\nThis must not be specified if the `ssl_key_file` option is specified.", + "type": "string" + }, + "ssl_key_file": { + "description": "Path to the client key for SSL connections\n\nThis must not be specified if the `ssl_key` option is specified.", + "type": "string" + }, "max_connections": { "description": "Set the maximum number of connections the pool should maintain", "default": 10, @@ -1044,6 +1076,53 @@ "type": "string", "format": "hostname" }, + "PgSslMode": { + "description": "Options for controlling the level of protection provided for PostgreSQL SSL connections.", + "oneOf": [ + { + "description": "Only try a non-SSL connection.", + "type": "string", + "enum": [ + "disable" + ] + }, + { + "description": "First try a non-SSL connection; if that fails, try an SSL connection.", + "type": "string", + "enum": [ + "allow" + ] + }, + { + "description": "First try an SSL connection; if that fails, try a non-SSL connection.", + "type": "string", + "enum": [ + "prefer" + ] + }, + { + "description": "Only try an SSL connection. If a root CA file is present, verify the connection in the same way as if `VerifyCa` was specified.", + "type": "string", + "enum": [ + "require" + ] + }, + { + "description": "Only try an SSL connection, and verify that the server certificate is issued by a trusted certificate authority (CA).", + "type": "string", + "enum": [ + "verify-ca" + ] + }, + { + "description": "Only try an SSL connection; verify that the server certificate is issued by a trusted CA and that the requested server host name matches that in the certificate.", + "type": "string", + "enum": [ + "verify-full" + ] + } + ] + }, "TelemetryConfig": { "description": "Configuration related to sending monitoring data", "type": "object",