Skip to content

Commit 63cdbb4

Browse files
authored
Merge pull request #198 from NodeDB-Lab/fix/trust-superuser-identity
fix(auth): materialize trust superuser identity
2 parents ae3cb99 + 74febcf commit 63cdbb4

40 files changed

Lines changed: 1577 additions & 827 deletions

nodedb-test-support/src/pgwire_harness/multicore.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ use nodedb::config::auth::AuthMode;
1313
use nodedb::control::server::pgwire::listener::PgListener;
1414
use nodedb::control::state::SharedState;
1515
use nodedb::event::{EventPlane, create_event_bus};
16-
use nodedb::types::TenantId;
1716
use nodedb::wal::WalManager;
1817

1918
use super::support::{bind_http_listener, bind_native_listener, init_test_memory_governor};
@@ -41,12 +40,9 @@ impl TestServer {
4140
nodedb::control::security::credential::store::CredentialStore::open(&catalog_path)
4241
.unwrap(),
4342
);
44-
let _ = credentials.create_user(
45-
"nodedb",
46-
"nodedb",
47-
TenantId::new(1),
48-
vec![nodedb::control::security::identity::Role::Superuser],
49-
);
43+
credentials
44+
.bootstrap_trust_superuser("nodedb")
45+
.expect("bootstrap trust superuser");
5046
let mut shared =
5147
SharedState::new_with_credentials(dispatcher, Arc::clone(&wal), credentials)
5248
.expect("build shared state");

nodedb-test-support/src/pgwire_harness/restart.rs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ use nodedb::config::auth::AuthMode;
1313
use nodedb::control::server::pgwire::listener::PgListener;
1414
use nodedb::control::state::SharedState;
1515
use nodedb::event::{EventPlane, create_event_bus};
16-
use nodedb::types::TenantId;
1716
use nodedb::wal::WalManager;
1817

1918
use super::support::{bind_http_listener, bind_native_listener, init_test_memory_governor};
@@ -121,6 +120,15 @@ impl TestServer {
121120
(server, data_dir)
122121
}
123122

123+
/// Reopen a credential store in trust mode without provisioning the normal
124+
/// harness superuser. The returned server has no preconnected harness client;
125+
/// callers can exercise the production bootstrap path explicitly.
126+
pub async fn open_on_path_empty_store_trust(dir: TestDataDir) -> (Self, TestDataDir) {
127+
let data_dir = TestDataDir(dir.0);
128+
let server = Self::start_on_dir_ref(data_dir.path(), None, AuthMode::Trust, false).await;
129+
(server, data_dir)
130+
}
131+
124132
/// Reopen an empty credential store in password mode without provisioning
125133
/// the normal harness superuser. The returned server has no preconnected
126134
/// harness client; callers must open the connection under test themselves.
@@ -175,12 +183,14 @@ impl TestServer {
175183
.unwrap(),
176184
);
177185
if provision_superuser {
178-
let _ = credentials.create_user(
179-
"nodedb",
180-
"nodedb",
181-
TenantId::new(1),
182-
vec![nodedb::control::security::identity::Role::Superuser],
183-
);
186+
match auth_mode {
187+
AuthMode::Trust => credentials
188+
.bootstrap_trust_superuser("nodedb")
189+
.expect("bootstrap trust superuser"),
190+
_ => credentials
191+
.bootstrap_superuser("nodedb", "nodedb")
192+
.expect("bootstrap password superuser"),
193+
}
184194
}
185195
let mut shared =
186196
SharedState::new_with_credentials(dispatcher, Arc::clone(&wal), credentials)

nodedb-test-support/src/pgwire_harness/start.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -161,17 +161,17 @@ impl TestServer {
161161
credential_store.set_lockout_policy(max_failed, lockout_secs, 0);
162162
}
163163
let credentials = Arc::new(credential_store);
164-
// Provision the harness superuser `nodedb` so Trust-mode strict
165-
// identity resolution accepts the default test connection. The
166-
// bootstrap exception in the handler only fires when the store
167-
// is empty, which would break as soon as any DDL creates a user.
164+
// Mirror production bootstrap so trust-mode listeners resolve a durable
165+
// configured principal rather than fabricating an ephemeral identity.
168166
if cfg.provision_superuser {
169-
let _ = credentials.create_user(
170-
"nodedb",
171-
"nodedb",
172-
nodedb::types::TenantId::new(1),
173-
vec![nodedb::control::security::identity::Role::Superuser],
174-
);
167+
match cfg.auth_mode {
168+
AuthMode::Trust => credentials
169+
.bootstrap_trust_superuser("nodedb")
170+
.expect("bootstrap trust superuser"),
171+
_ => credentials
172+
.bootstrap_superuser("nodedb", "nodedb")
173+
.expect("bootstrap password superuser"),
174+
}
175175
}
176176
// Ensure the built-in `default` database (id 0) is present in the
177177
// catalog so `USE DATABASE default` and `\c default` work in tests.

nodedb/src/bootstrap/credentials.rs

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,36 +43,42 @@ pub fn replay_surrogate_wal(shared: &Arc<SharedState>, wal_records: &Arc<[WalRec
4343
}
4444
}
4545

46-
/// Bootstrap the superuser credential from config / data dir, or warn about trust mode.
46+
/// Materialize the configured superuser before any listener starts.
4747
pub fn bootstrap_superuser(shared: &Arc<SharedState>, config: &ServerConfig) -> anyhow::Result<()> {
48-
let auth_mode = config.auth.mode.clone();
49-
match config
50-
.auth
51-
.resolve_superuser_password(&config.server.data_dir)
52-
{
53-
Ok(Some(password)) => {
48+
match &config.auth.mode {
49+
crate::config::auth::AuthMode::Trust => {
5450
shared
5551
.credentials
56-
.bootstrap_superuser(&config.auth.superuser_name, &password)?;
52+
.bootstrap_trust_superuser(&config.auth.superuser_name)?;
5753
info!(
5854
user = config.auth.superuser_name,
59-
mode = ?auth_mode,
60-
"superuser bootstrapped"
55+
mode = ?config.auth.mode,
56+
"trust-mode superuser bootstrapped"
6157
);
62-
}
63-
Ok(None) => {
64-
// Trust mode — no credentials needed, but operators must opt in explicitly.
6558
warn!("╔══════════════════════════════════════════════════════════════╗");
6659
warn!("║ WARNING: NodeDB is running in TRUST mode. ║");
67-
warn!("║ ALL connections are accepted WITHOUT credentials. ║");
60+
warn!("║ Password credentials are NOT verified. ║");
6861
warn!("║ This is UNSAFE for any environment beyond local dev/CI. ║");
6962
warn!("║ Set auth.mode = \"password\" (or \"certificate\") to require ║");
7063
warn!("║ credentials. Trust mode must be an explicit operator ║");
7164
warn!("║ opt-in — it is never the NodeDB default. ║");
7265
warn!("╚══════════════════════════════════════════════════════════════╝");
7366
}
74-
Err(e) => {
75-
return Err(e.into());
67+
crate::config::auth::AuthMode::Password | crate::config::auth::AuthMode::Certificate => {
68+
let password = config
69+
.auth
70+
.resolve_superuser_password(&config.server.data_dir)?
71+
.ok_or_else(|| crate::Error::Config {
72+
detail: "superuser password resolution returned no credential".into(),
73+
})?;
74+
shared
75+
.credentials
76+
.bootstrap_superuser(&config.auth.superuser_name, &password)?;
77+
info!(
78+
user = config.auth.superuser_name,
79+
mode = ?config.auth.mode,
80+
"superuser bootstrapped"
81+
);
7682
}
7783
}
7884
Ok(())

nodedb/src/control/security/catalog/system_catalog.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ use super::types::*;
2020
#[derive(Clone)]
2121
pub struct SystemCatalog {
2222
pub(super) db: Arc<Database>,
23+
#[cfg(test)]
24+
pub(super) fail_next_user_counter_write: Arc<std::sync::atomic::AtomicBool>,
2325
}
2426

2527
impl SystemCatalog {
@@ -37,7 +39,11 @@ impl SystemCatalog {
3739
info!(path = %path.display(), "system catalog opened");
3840
}
3941

40-
Ok(Self { db: Arc::new(db) })
42+
Ok(Self {
43+
db: Arc::new(db),
44+
#[cfg(test)]
45+
fail_next_user_counter_write: Arc::new(std::sync::atomic::AtomicBool::new(false)),
46+
})
4147
}
4248

4349
/// Open a non-persistent system catalog backed by an in-memory redb
@@ -48,7 +54,17 @@ impl SystemCatalog {
4854
.create_with_backend(redb::backends::InMemoryBackend::new())
4955
.map_err(|e| catalog_err("open in-memory", e))?;
5056
Self::ensure_bootstrapped(&db)?;
51-
Ok(Self { db: Arc::new(db) })
57+
Ok(Self {
58+
db: Arc::new(db),
59+
#[cfg(test)]
60+
fail_next_user_counter_write: Arc::new(std::sync::atomic::AtomicBool::new(false)),
61+
})
62+
}
63+
64+
#[cfg(test)]
65+
pub(crate) fn fail_next_user_counter_write_for_test(&self) {
66+
self.fail_next_user_counter_write
67+
.store(true, std::sync::atomic::Ordering::SeqCst);
5268
}
5369

5470
/// Bootstrap every `_system.*` table from the canonical registry —

nodedb/src/control/security/catalog/users.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
//! User CRUD operations for the system catalog.
44
5-
use super::types::{StoredUser, SystemCatalog, USERS, catalog_err};
5+
use super::types::{METADATA, StoredUser, SystemCatalog, USERS, catalog_err};
66

77
impl SystemCatalog {
88
/// Load all active users from the catalog.
@@ -71,6 +71,46 @@ impl SystemCatalog {
7171
Ok(())
7272
}
7373

74+
/// Atomically write a newly allocated user and advance the durable ID counter.
75+
pub fn put_user_with_next_user_id(
76+
&self,
77+
user: &StoredUser,
78+
next_user_id: u64,
79+
) -> crate::Result<()> {
80+
let bytes = zerompk::to_msgpack_vec(user).map_err(|e| catalog_err("serialize user", e))?;
81+
let write_txn = self
82+
.db
83+
.begin_write()
84+
.map_err(|e| catalog_err("write txn", e))?;
85+
{
86+
let mut users = write_txn
87+
.open_table(USERS)
88+
.map_err(|e| catalog_err("open users", e))?;
89+
users
90+
.insert(user.username.as_str(), bytes.as_slice())
91+
.map_err(|e| catalog_err("insert user", e))?;
92+
}
93+
{
94+
let mut metadata = write_txn
95+
.open_table(METADATA)
96+
.map_err(|e| catalog_err("open metadata", e))?;
97+
metadata
98+
.insert("next_user_id", next_user_id.to_le_bytes().as_slice())
99+
.map_err(|e| catalog_err("insert next_user_id", e))?;
100+
}
101+
#[cfg(test)]
102+
if self
103+
.fail_next_user_counter_write
104+
.swap(false, std::sync::atomic::Ordering::SeqCst)
105+
{
106+
return Err(crate::Error::Storage {
107+
engine: "catalog".into(),
108+
detail: "injected user/counter transaction failure".into(),
109+
});
110+
}
111+
write_txn.commit().map_err(|e| catalog_err("commit", e))
112+
}
113+
74114
/// Delete a user record from the catalog.
75115
pub fn delete_user(&self, username: &str) -> crate::Result<()> {
76116
let write_txn = self

nodedb/src/control/security/credential/lockout.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use crate::control::security::audit::{AuditEmitContext, AuditEmitter, AuditEvent
1010
use crate::control::security::catalog::StoredLockoutRecord;
1111
use crate::types::TenantId;
1212

13-
use super::store::{CredentialStore, read_lock, write_lock};
13+
use super::store::core::{CredentialStore, read_lock, write_lock};
1414

1515
/// Tracks failed login attempts for lockout enforcement.
1616
#[derive(Debug, Clone)]

nodedb/src/control/security/credential/record.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,68 @@ impl UserRecord {
138138
}
139139
}
140140
}
141+
142+
#[cfg(test)]
143+
mod tests {
144+
use super::*;
145+
146+
#[test]
147+
fn backward_compat_stored_user_defaults() {
148+
let created_at_epoch: u64 = 1_700_000_000;
149+
let mut buf = Vec::new();
150+
rmpv::encode::write_value(
151+
&mut buf,
152+
&rmpv::Value::Map(vec![
153+
(
154+
rmpv::Value::String("user_id".into()),
155+
rmpv::Value::Integer(rmpv::Integer::from(42u64)),
156+
),
157+
(
158+
rmpv::Value::String("username".into()),
159+
rmpv::Value::String("legacy_user".into()),
160+
),
161+
(
162+
rmpv::Value::String("tenant_id".into()),
163+
rmpv::Value::Integer(rmpv::Integer::from(1u32)),
164+
),
165+
(
166+
rmpv::Value::String("password_hash".into()),
167+
rmpv::Value::String("$argon2id$fake_hash".into()),
168+
),
169+
(
170+
rmpv::Value::String("scram_salt".into()),
171+
rmpv::Value::Binary(vec![1, 2, 3]),
172+
),
173+
(
174+
rmpv::Value::String("scram_salted_password".into()),
175+
rmpv::Value::Binary(vec![4, 5, 6]),
176+
),
177+
(
178+
rmpv::Value::String("roles".into()),
179+
rmpv::Value::Array(vec![rmpv::Value::String("read_write".into())]),
180+
),
181+
(
182+
rmpv::Value::String("is_superuser".into()),
183+
rmpv::Value::Boolean(false),
184+
),
185+
(
186+
rmpv::Value::String("is_active".into()),
187+
rmpv::Value::Boolean(true),
188+
),
189+
(
190+
rmpv::Value::String("created_at".into()),
191+
rmpv::Value::Integer(rmpv::Integer::from(created_at_epoch)),
192+
),
193+
]),
194+
)
195+
.expect("encode legacy StoredUser");
196+
197+
let stored: StoredUser = zerompk::from_msgpack(&buf).expect("decode legacy StoredUser");
198+
assert!(!stored.must_change_password);
199+
assert_eq!(stored.password_changed_at, 0);
200+
201+
let record = UserRecord::from_stored(stored);
202+
assert!(!record.must_change_password);
203+
assert_eq!(record.password_changed_at, created_at_epoch);
204+
}
205+
}

0 commit comments

Comments
 (0)