Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .schema/users.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,13 @@
"format": "uint64",
"minimum": 0
},
"tls_client_certificate_required": {
"description": "Require this user to present a client TLS certificate. Defaults to `true`.\n\nOnly enforced when `tls_client_ca_certificate` is configured and the client\nconnected over TLS. Set to `false` to let password and mTLS users share a listener.",
"type": [
"boolean",
"null"
]
},
"two_phase_commit": {
"description": "Overrides [`two_phase_commit`](https://docs.pgdog.dev/configuration/pgdog.toml/general/#two_phase_commit) for this user.\n\n<https://docs.pgdog.dev/configuration/users.toml/users/#two_phase_commit>",
"type": [
Expand Down
5 changes: 5 additions & 0 deletions example.users.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,8 @@ password = "pgdog"
# server_user = "pgdog_service"
# server_auth = "vault_static"
# server_vault_path = "database/static-creds/pgdog-service"

# Example: let this user authenticate with a password over TLS without presenting
# a client certificate, while mTLS users share the same listener. Defaults to true.
# Only applies when `tls_client_ca_certificate` is set in pgdog.toml.
# tls_client_certificate_required = false
27 changes: 27 additions & 0 deletions integration/tls/dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@ PORT=6432
DB=pgdog
USER_A=tls_user_a
USER_B=tls_user_b
USER_C=tls_user_c
USER_D=tls_user_d

run_psql() {
psql "host=$HOST port=$PORT dbname=$DB user=$1 sslmode=require sslcert=$DIR/$2.crt sslkey=$DIR/$2.key" -c "SELECT 1" > /dev/null 2>&1
}

# TLS without offering a client certificate.
run_psql_no_cert() {
PGPASSWORD=pgdog psql "host=$HOST port=$PORT dbname=$DB user=$1 sslmode=require" -c "SELECT 1" > /dev/null 2>&1
}

if [ "${1:-}" = "--source-only" ]; then
return 0 2>/dev/null || exit 0
fi
Expand Down Expand Up @@ -71,6 +78,26 @@ else
PASS=$((PASS + 1))
fi

# Test 6: password user opted out of client certs, TLS but no cert (should succeed)
echo -n "$USER_C opted out + no cert: "
if run_psql_no_cert "$USER_C"; then
echo "OK"
PASS=$((PASS + 1))
else
echo "FAIL (expected success)"
FAIL=$((FAIL + 1))
fi

# Test 7: password user that did not opt out, TLS but no cert (should fail)
echo -n "$USER_D not opted out + no cert: "
if run_psql_no_cert "$USER_D"; then
echo "FAIL (expected rejection)"
FAIL=$((FAIL + 1))
else
echo "OK (rejected)"
PASS=$((PASS + 1))
fi

echo ""
echo "Results: $PASS passed, $FAIL failed"

Expand Down
18 changes: 18 additions & 0 deletions integration/tls/users.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,21 @@ database = "pgdog"
identity = "pgdog2"
server_user = "pgdog"
server_password = "pgdog"

# Password user that opts out of client certificates, sharing the listener
# with the mTLS users above.
[[users]]
name = "tls_user_c"
database = "pgdog"
password = "pgdog"
tls_client_certificate_required = false
server_user = "pgdog"
server_password = "pgdog"

# Password user that does not opt out, so a certificate is still required.
[[users]]
name = "tls_user_d"
database = "pgdog"
password = "pgdog"
server_user = "pgdog"
server_password = "pgdog"
35 changes: 35 additions & 0 deletions pgdog-config/src/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,11 @@ impl Display for PasswordKind {
pub struct User {
/// User identity used for mTLS.
pub identity: Option<String>,
/// Require this user to present a client TLS certificate. Defaults to `true`.
///
/// Only enforced when `tls_client_ca_certificate` is configured and the client
/// connected over TLS. Set to `false` to let password and mTLS users share a listener.
pub tls_client_certificate_required: Option<bool>,
/// Name of the user. Clients that connect to PgDog will need to use this username.
///
/// <https://docs.pgdog.dev/configuration/users.toml/users/#name>
Expand Down Expand Up @@ -718,6 +723,36 @@ server_auth = "azure_workload_identity"
assert_eq!(user.server_auth, ServerAuth::AzureWorkloadIdentity);
}

#[test]
fn test_tls_client_certificate_required_unset() {
let source = r#"
[[users]]
name = "alice"
database = "db"
password = "secret"
"#;

let users: Users = toml::from_str(source).unwrap();
let user = users.users.first().unwrap();
// Unset resolves to `true` when the cluster is built.
assert_eq!(user.tls_client_certificate_required, None);
}

#[test]
fn test_tls_client_certificate_required_opt_out() {
let source = r#"
[[users]]
name = "alice"
database = "db"
password = "secret"
tls_client_certificate_required = false
"#;

let users: Users = toml::from_str(source).unwrap();
let user = users.users.first().unwrap();
assert_eq!(user.tls_client_certificate_required, Some(false));
}

#[test]
fn test_user_server_auth_vault_dynamic() {
let source = r#"
Expand Down
24 changes: 24 additions & 0 deletions pgdog/src/auth/auth_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pub enum AuthResult {
NoPasswordConfig,
/// User identity (TLS cert) doesn't match configured identity.
NoIdentity,
/// User requires a client TLS certificate but didn't provide one.
NoClientCertificate,
/// Passthrough auth says user doesn't exist.
NoPassthroughNoUser,
/// Passthrough auth doesn't allow password changes.
Expand Down Expand Up @@ -40,6 +42,12 @@ impl Display for AuthResult {
Self::NoPasswordMatch => write!(f, "wrong password"),
Self::NoPasswordConfig => write!(f, "user has no passwords in config"),
Self::NoIdentity => write!(f, "user identity does not match certificate"),
Self::NoClientCertificate => {
write!(
f,
"user requires a client certificate but none was provided"
)
}
Self::NoPassthroughNoUser => write!(f, "no user in config (passthrough auth)"),
Self::NoPassthroughPasswordChange => {
write!(f, "passthrough auth does not allow password change")
Expand All @@ -49,3 +57,19 @@ impl Display for AuthResult {
}
}
}

#[cfg(test)]
mod tests {
use super::AuthResult;

#[test]
fn no_client_certificate_is_an_error_and_explains_itself() {
let result = AuthResult::NoClientCertificate;

assert!(!result.is_ok());
assert_eq!(
result.to_string(),
"user requires a client certificate but none was provided"
);
}
}
10 changes: 10 additions & 0 deletions pgdog/src/backend/pool/cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ pub struct Cluster {
resharding_replication_retry_min_delay: Duration,
regex_parser: RegexParser,
identity: Option<String>,
tls_client_certificate_required: bool,
}

/// Sharding configuration from the cluster.
Expand Down Expand Up @@ -165,6 +166,7 @@ pub struct ClusterConfig<'a> {
regex_parser_limit: usize,
pub_sub_enabled: bool,
identity: &'a Option<String>,
tls_client_certificate_required: bool,
schema_cache: SchemaCache,
}

Expand Down Expand Up @@ -235,6 +237,7 @@ impl<'a> ClusterConfig<'a> {
regex_parser_limit: general.regex_parser_limit,
pub_sub_enabled: general.pub_sub_enabled(),
identity: &user.identity,
tls_client_certificate_required: user.tls_client_certificate_required.unwrap_or(true),
schema_cache,
}
}
Expand Down Expand Up @@ -282,6 +285,7 @@ impl Cluster {
regex_parser_limit,
pub_sub_enabled,
identity,
tls_client_certificate_required,
schema_cache,
} = config;

Expand Down Expand Up @@ -345,6 +349,7 @@ impl Cluster {
),
regex_parser: RegexParser::new(regex_parser_limit, query_parser),
identity: identity.clone(),
tls_client_certificate_required,
}
}

Expand Down Expand Up @@ -413,6 +418,11 @@ impl Cluster {
self.identity.as_deref()
}

/// This user must present a client TLS certificate when connecting over TLS.
pub fn tls_client_certificate_required(&self) -> bool {
self.tls_client_certificate_required
}

/// User name.
pub fn user(&self) -> &str {
&self.identifier.user
Expand Down
2 changes: 1 addition & 1 deletion pgdog/src/backend/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ impl Server {
Ok(tls_stream) => {
debug!("TLS handshake successful with {}", addr.host);
let cipher = tokio_rustls::TlsStream::Client(tls_stream);
stream = Stream::tls(cipher, config.config.memory.net_buffer, None);
stream = Stream::tls(cipher, config.config.memory.net_buffer, None, false);
}
Err(e) => {
error!("TLS handshake failed with {:?} [{}]", e, addr);
Expand Down
90 changes: 90 additions & 0 deletions pgdog/src/frontend/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ pub struct Client {
query_size_limit: Option<usize>,
}

/// Inputs to the per-user client certificate check.
struct ClientCertificateCheck {
/// A client CA is configured, so certificates are requested at all.
client_ca_configured: bool,
/// The client connected over TLS.
is_tls: bool,
/// This user's `tls_client_certificate_required`.
required: bool,
/// The client presented a certificate during the handshake.
presented: bool,
}

impl ClientCertificateCheck {
/// The client owed us a certificate and didn't send one.
///
/// Only meaningful when a client CA is configured, since otherwise no
/// certificate is ever requested, and only over TLS, since plaintext is
/// governed by `tls_client_required`.
fn rejected(&self) -> bool {
self.client_ca_configured && self.is_tls && self.required && !self.presented
}
}

impl Client {
/// Create new frontend client from the a TCP socket.
///
Expand Down Expand Up @@ -239,6 +262,9 @@ impl Client {
let key = BackendKeyData::new_frontend(protocol_version, id);
let comms = ClientComms::new(id);
let log_connections = config.config.general.log_connections;
// Without a client CA, no certificate is ever requested, so requiring one
// could never be satisfied.
let client_ca_configured = config.config.general.tls_client_ca_certificate.is_some();

// Check if we need to ask the client for its password in plaintext
// because we don't actually have it configured.
Expand Down Expand Up @@ -279,6 +305,17 @@ impl Client {
} else {
AuthResult::NoIdentity
}
} else if (ClientCertificateCheck {
client_ca_configured,
is_tls: stream.is_tls(),
required: cluster.tls_client_certificate_required(),
presented: stream.tls_client_certificate(),
})
.rejected()
{
// Asked for a certificate and declined. Users that opt out
// fall through to password authentication instead.
AuthResult::NoClientCertificate
} else {
// Resolve Vault static role
// entries to plaintext before the auth exchange
Expand Down Expand Up @@ -708,6 +745,59 @@ impl MemoryUsage for Client {
#[cfg(test)]
pub mod test;

#[cfg(test)]
mod client_certificate_tests {
use super::ClientCertificateCheck;

/// A user that owes a certificate over TLS and didn't send one.
fn rejected() -> ClientCertificateCheck {
ClientCertificateCheck {
client_ca_configured: true,
is_tls: true,
required: true,
presented: false,
}
}

#[test]
fn rejects_only_when_a_certificate_was_owed_and_withheld() {
assert!(rejected().rejected());

// Opted out with `tls_client_certificate_required = false`.
assert!(
!ClientCertificateCheck {
required: false,
..rejected()
}
.rejected()
);
// Presented a certificate.
assert!(
!ClientCertificateCheck {
presented: true,
..rejected()
}
.rejected()
);
// Plaintext: governed by `tls_client_required` instead.
assert!(
!ClientCertificateCheck {
is_tls: false,
..rejected()
}
.rejected()
);
// No client CA: a certificate is never requested, so none can be owed.
assert!(
!ClientCertificateCheck {
client_ca_configured: false,
..rejected()
}
.rejected()
);
}
}

#[derive(Copy, Clone, PartialEq, Debug)]
enum BufferEvent {
DisconnectGraceful,
Expand Down
4 changes: 3 additions & 1 deletion pgdog/src/frontend/listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::backend::databases::{databases, reload, shutdown};
use crate::config::config;
use crate::frontend::client::query_engine::two_pc::Manager;
use crate::net::messages::{FrontendPid, NegotiateProtocolVersion, Startup, hello::SslReply};
use crate::net::tls::{acceptor, peer_identity};
use crate::net::tls::{acceptor, peer_certificate_present, peer_identity};
use crate::net::{self, Stream, tweak};
use crate::sighup::Sighup;
use tokio::net::{TcpListener, TcpStream};
Expand Down Expand Up @@ -198,10 +198,12 @@ impl Listener {
}
};
let tls_identity = peer_identity(cipher.get_ref().1);
let tls_client_certificate = peer_certificate_present(cipher.get_ref().1);
stream = Stream::tls(
tokio_rustls::TlsStream::Server(cipher),
config.config.memory.net_buffer,
tls_identity,
tls_client_certificate,
);
} else {
stream.send_flush(&SslReply::No).await?;
Expand Down
Loading
Loading