Skip to content

Commit a2fdf9e

Browse files
committed
fix(client): remove default trust identity from PoolConfig
Trust authentication is passwordless, so defaulting PoolConfig's identity to username "admin" let a caller who forgot to configure auth silently connect as whatever account that default named. Drop PoolConfig::default and AuthMethod::Trust's serde username default, require an explicit auth identity via PoolConfig::new / ConnectionBuilder::build (now fallible), and update NativeClient, tests, and the cluster test harness to supply identity explicitly.
1 parent f6fd6d9 commit a2fdf9e

9 files changed

Lines changed: 302 additions & 86 deletions

File tree

nodedb-client-tests/tests/native_execute_sql_params_round_trip.rs

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,14 @@ use nodedb_test_support::pgwire_harness::TestServer;
1818
async fn native_execute_sql_with_bound_params_round_trips() {
1919
let server = TestServer::start().await;
2020

21-
let pool = PoolConfig {
22-
addr: format!("127.0.0.1:{}", server.native_port),
23-
..Default::default()
24-
};
25-
// The harness provisions superuser `nodedb`; override the default
26-
// PoolConfig auth username from `admin`.
27-
let pool = PoolConfig {
28-
auth: nodedb_types::protocol::AuthMethod::Trust {
21+
// The harness provisions superuser `nodedb`; `PoolConfig` has no default
22+
// identity (see `PoolConfig::new`'s doc comment), so state it explicitly.
23+
let pool = PoolConfig::new(
24+
format!("127.0.0.1:{}", server.native_port),
25+
nodedb_types::protocol::AuthMethod::Trust {
2926
username: "nodedb".into(),
3027
},
31-
..pool
32-
};
28+
);
3329
let native = NativeClient::new(pool);
3430

3531
// Spec: a bound parameter binds to `$1` and the server returns a

nodedb-client/src/capabilities.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use nodedb_types::protocol::{
1818
///
1919
/// # Example
2020
/// ```no_run
21-
/// # use nodedb_client::native::capabilities::Capabilities;
21+
/// # use nodedb_client::capabilities::Capabilities;
2222
/// let caps = Capabilities::from_raw(0x07); // streaming + graphrag + fts
2323
/// assert!(caps.supports_streaming());
2424
/// assert!(caps.supports_graphrag());

nodedb-client/src/native/builder.rs

Lines changed: 133 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
//! .password("s3cr3t")
99
//! .database("analytics")
1010
//! .max_connections(20)
11-
//! .build();
11+
//! .build()?;
12+
//! # Ok::<(), nodedb_types::error::NodeDbError>(())
1213
//! ```
1314
1415
use std::time::Duration;
1516

17+
use nodedb_types::error::{NodeDbError, NodeDbResult};
1618
use nodedb_types::protocol::AuthMethod;
1719

1820
use super::client::NativeClient;
@@ -99,55 +101,162 @@ impl ConnectionBuilder {
99101

100102
/// Build the `NativeClient`.
101103
///
102-
/// Falls back to sensible defaults for any unset option.
103-
pub fn build(self) -> NativeClient {
104+
/// The connection address falls back to `127.0.0.1:6433` when unset —
105+
/// that is a convenience default, not a security decision. Identity is
106+
/// the one option with no default: trust auth is passwordless, so a
107+
/// defaulted username would silently authenticate as whatever that
108+
/// default happened to be (privilege escalation by omission). Callers
109+
/// must supply either [`api_key`](Self::api_key) (which carries its own
110+
/// identity via the token) or [`username`](Self::username) — do not
111+
/// reintroduce a fallback for either. Never add a default for `addr`
112+
/// beyond the existing one either; keep the two concerns separate.
113+
///
114+
/// # Errors
115+
///
116+
/// Returns an error if neither `api_key` nor `username` was set.
117+
pub fn build(self) -> NodeDbResult<NativeClient> {
104118
let addr = self.addr.unwrap_or_else(|| "127.0.0.1:6433".to_string());
119+
let auth = resolve_auth(self.username, self.password, self.api_key)?;
105120

106-
let auth = if let Some(token) = self.api_key {
107-
AuthMethod::ApiKey { token }
108-
} else if let Some(password) = self.password {
109-
let username = self.username.unwrap_or_else(|| "admin".to_string());
110-
AuthMethod::Password { username, password }
111-
} else {
112-
let username = self.username.unwrap_or_else(|| "admin".to_string());
113-
AuthMethod::Trust { username }
114-
};
115-
116-
let default_config = PoolConfig::default();
121+
// `PoolConfig::new` already carries the identity (`auth`, always
122+
// built above — never omitted) plus this crate's tuning defaults;
123+
// only override the tuning fields the caller actually set.
124+
let default_config = PoolConfig::new(addr, auth);
117125

118126
let config = PoolConfig {
119-
addr,
120-
auth,
121127
database: self.database,
122128
max_size: self.max_connections.unwrap_or(default_config.max_size),
123129
connect_timeout: self
124130
.connect_timeout
125131
.unwrap_or(default_config.connect_timeout),
126132
idle_timeout: self.idle_timeout.unwrap_or(default_config.idle_timeout),
127133
tls: self.tls.unwrap_or_default(),
134+
..default_config
128135
};
129136

130-
NativeClient::new(config)
137+
Ok(NativeClient::new(config))
138+
}
139+
}
140+
141+
/// Resolve the builder's optional identity fields into a required
142+
/// [`AuthMethod`].
143+
///
144+
/// This is a disjunction, not three independent defaults: an `api_key`
145+
/// carries its own identity via the token, so it alone is sufficient. The
146+
/// trust and password branches have no such built-in identity, so they
147+
/// require an explicit `username` — there is no fallback for either.
148+
fn resolve_auth(
149+
username: Option<String>,
150+
password: Option<String>,
151+
api_key: Option<String>,
152+
) -> NodeDbResult<AuthMethod> {
153+
if let Some(token) = api_key {
154+
return Ok(AuthMethod::ApiKey { token });
131155
}
156+
let username = username.ok_or_else(|| {
157+
NodeDbError::config(
158+
"no authentication identity configured: call .username(...) or .api_key(...)",
159+
)
160+
})?;
161+
Ok(match password {
162+
Some(password) => AuthMethod::Password { username, password },
163+
None => AuthMethod::Trust { username },
164+
})
132165
}
133166

134167
#[cfg(test)]
135168
mod tests {
136169
use super::*;
137170

138171
#[test]
139-
fn builder_defaults() {
140-
let client = ConnectionBuilder::new("127.0.0.1:6433").build();
141-
let _ = client; // just verify it compiles
172+
fn resolve_auth_with_username_carries_it_into_trust() {
173+
let auth = resolve_auth(Some("alice".to_string()), None, None).expect("username given");
174+
match auth {
175+
AuthMethod::Trust { username } => assert_eq!(username, "alice"),
176+
other => panic!("expected AuthMethod::Trust, got {other:?}"),
177+
}
178+
}
179+
180+
#[test]
181+
fn resolve_auth_with_username_and_password_carries_it_into_password() {
182+
let auth = resolve_auth(Some("bob".to_string()), Some("secret".to_string()), None)
183+
.expect("username given");
184+
match auth {
185+
AuthMethod::Password { username, password } => {
186+
assert_eq!(username, "bob");
187+
assert_eq!(password, "secret");
188+
}
189+
other => panic!("expected AuthMethod::Password, got {other:?}"),
190+
}
191+
}
192+
193+
#[test]
194+
fn resolve_auth_with_api_key_succeeds_without_username() {
195+
let auth = resolve_auth(None, None, Some("token-123".to_string())).expect("api_key given");
196+
assert!(matches!(auth, AuthMethod::ApiKey { token } if token == "token-123"));
197+
}
198+
199+
#[test]
200+
fn resolve_auth_with_no_identity_errors() {
201+
// Regression lock: no username, no api_key — must fail, never
202+
// silently authenticate as some default identity.
203+
let err = resolve_auth(None, None, None).expect_err("must reject a missing identity");
204+
assert!(err.message().contains("authentication identity"));
205+
}
206+
207+
#[test]
208+
fn resolve_auth_with_password_but_no_username_errors() {
209+
let err = resolve_auth(None, Some("secret".to_string()), None)
210+
.expect_err("must reject password auth without a username");
211+
assert!(err.message().contains("authentication identity"));
212+
}
213+
214+
#[test]
215+
fn builder_with_username_succeeds() {
216+
let client = ConnectionBuilder::new("127.0.0.1:6433")
217+
.username("alice")
218+
.build();
219+
assert!(client.is_ok());
220+
}
221+
222+
#[test]
223+
fn builder_with_api_key_succeeds_without_username() {
224+
let client = ConnectionBuilder::new("127.0.0.1:6433")
225+
.api_key("token-123")
226+
.build();
227+
assert!(client.is_ok());
228+
}
229+
230+
#[test]
231+
fn builder_with_no_identity_errors() {
232+
// Regression lock: no username, no api_key — must fail, never
233+
// silently authenticate as some default identity.
234+
// `NativeClient` is not `Debug` (it owns a live connection pool), so
235+
// the Ok payload is discarded before `expect_err`.
236+
let err = ConnectionBuilder::new("127.0.0.1:6433")
237+
.build()
238+
.map(|_| ())
239+
.expect_err("build() must reject a missing identity");
240+
assert!(err.message().contains("authentication identity"));
241+
}
242+
243+
#[test]
244+
fn builder_password_without_username_errors() {
245+
let err = ConnectionBuilder::new("127.0.0.1:6433")
246+
.password("secret")
247+
.build()
248+
.map(|_| ())
249+
.expect_err("build() must reject password auth without a username");
250+
assert!(err.message().contains("authentication identity"));
142251
}
143252

144253
#[test]
145254
fn builder_with_database() {
146-
// Smoke test: verify the builder accepts a database name without panic.
147255
let _client = ConnectionBuilder::new("127.0.0.1:6433")
148256
.username("alice")
149257
.database("analytics")
150-
.build();
258+
.build()
259+
.expect("username was supplied, build() must succeed");
151260
}
152261

153262
#[test]
@@ -156,6 +265,7 @@ mod tests {
156265
.username("bob")
157266
.password("secret")
158267
.database("prod")
159-
.build();
268+
.build()
269+
.expect("username was supplied, build() must succeed");
160270
}
161271
}

nodedb-client/src/native/client/core.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! `NativeClient` struct definition and connection/session helpers.
44
55
use nodedb_types::error::{ErrorDetails, NodeDbError, NodeDbResult};
6+
use nodedb_types::protocol::AuthMethod;
67
use nodedb_types::result::QueryResult;
78

89
use super::super::pool::{Pool, PoolConfig};
@@ -23,12 +24,16 @@ impl NativeClient {
2324
}
2425
}
2526

26-
/// Connect to a NodeDB server with default settings.
27-
pub fn connect(addr: &str) -> Self {
28-
Self::new(PoolConfig {
29-
addr: addr.to_string(),
30-
..Default::default()
31-
})
27+
/// Connect to a NodeDB server, authenticating as `auth`.
28+
///
29+
/// The identity is a required argument, not a default: trust
30+
/// authentication is passwordless, so a default identity would let a
31+
/// caller who forgot to configure auth connect as whatever account the
32+
/// default names — privilege escalation by omission. Use
33+
/// [`PoolConfig::new`] plus [`NativeClient::new`] directly if other pool
34+
/// tuning (max size, timeouts, TLS, database) needs to be non-default too.
35+
pub fn connect_as(addr: &str, auth: AuthMethod) -> Self {
36+
Self::new(PoolConfig::new(addr, auth))
3237
}
3338

3439
/// Execute a SQL query and return structured results.

nodedb-client/src/native/pool.rs

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ use tokio::sync::{Semaphore, SemaphorePermit};
1818
use super::connection::NativeConnection;
1919

2020
/// Configuration for the connection pool.
21+
///
22+
/// There is no `Default` impl — construct via [`PoolConfig::new`], which
23+
/// requires an explicit `auth` identity. See that constructor's doc comment
24+
/// for why a defaulted identity is unsafe.
2125
#[derive(Debug, Clone)]
2226
pub struct PoolConfig {
2327
/// Server address (host:port).
@@ -28,7 +32,8 @@ pub struct PoolConfig {
2832
pub connect_timeout: Duration,
2933
/// Idle connection timeout (connections idle longer than this are dropped).
3034
pub idle_timeout: Duration,
31-
/// Authentication method.
35+
/// Authentication method. Required — no default identity (see
36+
/// [`PoolConfig::new`]).
3237
pub auth: AuthMethod,
3338
/// Target database name sent in the auth handshake frame.
3439
///
@@ -38,16 +43,40 @@ pub struct PoolConfig {
3843
pub tls: super::connection::TlsConfig,
3944
}
4045

41-
impl Default for PoolConfig {
42-
fn default() -> Self {
46+
impl PoolConfig {
47+
/// Construct a pool configuration with an explicit connection address and
48+
/// authentication identity.
49+
///
50+
/// There is intentionally no `Default` impl for `PoolConfig`: trust
51+
/// authentication is passwordless, so a server configured for trust auth
52+
/// grants full privileges to whatever username the client claims. A
53+
/// default identity (e.g. a hardcoded `admin`) would let a caller connect
54+
/// as the server's most privileged account simply by forgetting to
55+
/// configure auth — privilege escalation by omission. Every caller must
56+
/// state who they are connecting as.
57+
///
58+
/// Every other tuning field keeps its previous default and can still be
59+
/// overridden via struct-update syntax:
60+
///
61+
/// ```rust,ignore
62+
/// use nodedb_client::native::pool::PoolConfig;
63+
/// use nodedb_types::protocol::AuthMethod;
64+
///
65+
/// let auth = AuthMethod::Trust {
66+
/// username: "alice".into(),
67+
/// };
68+
/// let config = PoolConfig {
69+
/// max_size: 2,
70+
/// ..PoolConfig::new("127.0.0.1:6433", auth)
71+
/// };
72+
/// ```
73+
pub fn new(addr: impl Into<String>, auth: AuthMethod) -> Self {
4374
Self {
44-
addr: "127.0.0.1:6433".into(),
75+
addr: addr.into(),
4576
max_size: 10,
4677
connect_timeout: Duration::from_secs(5),
4778
idle_timeout: Duration::from_secs(300),
48-
auth: AuthMethod::Trust {
49-
username: "admin".into(),
50-
},
79+
auth,
5180
database: None,
5281
tls: Default::default(),
5382
}
@@ -222,19 +251,37 @@ impl Drop for PooledConnection<'_> {
222251
mod tests {
223252
use super::*;
224253

254+
fn trust(username: &str) -> AuthMethod {
255+
AuthMethod::Trust {
256+
username: username.to_string(),
257+
}
258+
}
259+
225260
#[test]
226-
fn pool_config_defaults() {
227-
let cfg = PoolConfig::default();
261+
fn pool_config_new_sets_tuning_defaults() {
262+
let cfg = PoolConfig::new("127.0.0.1:6433", trust("alice"));
228263
assert_eq!(cfg.addr, "127.0.0.1:6433");
229264
assert_eq!(cfg.max_size, 10);
230265
assert_eq!(cfg.connect_timeout, Duration::from_secs(5));
231266
}
232267

268+
/// `PoolConfig::new` must carry the caller's identity through to the
269+
/// built config unchanged — asserted explicitly so a future refactor
270+
/// cannot quietly reintroduce a hardcoded default identity.
271+
#[test]
272+
fn pool_config_new_carries_caller_identity() {
273+
let cfg = PoolConfig::new("127.0.0.1:6433", trust("alice"));
274+
match cfg.auth {
275+
AuthMethod::Trust { username } => assert_eq!(username, "alice"),
276+
other => panic!("expected AuthMethod::Trust, got {other:?}"),
277+
}
278+
}
279+
233280
#[test]
234281
fn pool_creates_semaphore() {
235282
let pool = Pool::new(PoolConfig {
236283
max_size: 5,
237-
..Default::default()
284+
..PoolConfig::new("127.0.0.1:6433", trust("alice"))
238285
});
239286
assert_eq!(pool.semaphore.available_permits(), 5);
240287
}

0 commit comments

Comments
 (0)