Skip to content

Commit ac2d047

Browse files
committed
docs(security): clarify auth scope boundaries and add hardening integration tests
Document that Argon2Config is intentionally cluster-wide with no per-database override, explaining the migration cost that would be required to change that. Document that the pgwire factory accepts only SCRAM-SHA-256 and explicitly prohibits JWT branches, directing future contributors to the correct entry points. Add auth_hardening_h integration tests covering the integrated guarantees across the security subsystem: per-database audit row tagging on all database DDL variants, CLONE DATABASE rejection for non-Superuser callers with PermissionDenied audit rows, and idle-timeout cache observability after ALTER DATABASE SET IDLE_TIMEOUT.
1 parent 0a7cf36 commit ac2d047

3 files changed

Lines changed: 169 additions & 0 deletions

File tree

nodedb/src/config/auth/config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
// SPDX-License-Identifier: BUSL-1.1
22

33
//! Core authentication configuration types.
4+
//!
5+
//! `Argon2Config` is **cluster-wide**. There is intentionally no
6+
//! per-database override: a `DatabaseDescriptor` does not carry password
7+
//! / Argon2 parameters, and downstream code must not branch hashing
8+
//! parameters on `database_id`. Hash verification and rehash-on-login
9+
//! both read this single config; per-database tuning would require
10+
//! versioning the hash format and a migration path that does not yet
11+
//! exist. If a per-database override is ever added, it must thread
12+
//! through every site that constructs an `argon2::Argon2` instance —
13+
//! a wide ripple, not a field on the descriptor.
414
515
use serde::{Deserialize, Serialize};
616

nodedb/src/control/server/pgwire/factory.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
// SPDX-License-Identifier: BUSL-1.1
22

3+
//! pgwire connection factory: SCRAM-SHA-256 / Argon2 authentication and
4+
//! session bootstrapping.
5+
//!
6+
//! **Auth scope**: pgwire authenticates exclusively via SCRAM-SHA-256 over
7+
//! the Postgres wire protocol. OIDC bearer tokens are NOT accepted here —
8+
//! the Postgres wire protocol has no clean way to carry a bearer without a
9+
//! non-standard extension or a sidecar proxy. OIDC bearer logins live on
10+
//! the native and HTTP entry points (see `control/security/oidc/`); do not
11+
//! add a JWT branch to this factory.
12+
313
use std::fmt::Debug;
414
use std::sync::Arc;
515

nodedb/tests/auth_hardening_h.rs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! Cross-cutting auth-hardening invariants.
4+
//!
5+
//! Combines per-database audit-row tagging, admin-DDL gating, and the
6+
//! session-revocation infrastructure to prove the integrated guarantees:
7+
//!
8+
//! - every database DDL produces an audit row carrying the right
9+
//! `database_id`;
10+
//! - `CLONE DATABASE` rejects non-Superuser callers (and emits a
11+
//! `PermissionDenied` audit) before reaching the underlying handler;
12+
//! - `ALTER DATABASE … SET IDLE_TIMEOUT` updates the in-memory cache that
13+
//! the idle-sweep loop consults each tick.
14+
15+
mod common;
16+
17+
use common::pgwire_auth_helpers::{
18+
assert_audit_has, cluster_admin_user, ddl_err, ddl_ok, make_state_with_catalog, readonly_user,
19+
superuser,
20+
};
21+
use nodedb::control::security::audit::AuditEvent;
22+
use nodedb_types::id::DatabaseId;
23+
24+
// ── H.1 Per-database audit smoke ────────────────────────────────────────────
25+
26+
/// Every database DDL produces a `Database*`-tagged audit row carrying
27+
/// `database_id`.
28+
#[tokio::test]
29+
async fn database_ddl_audit_smoke() {
30+
let state = make_state_with_catalog();
31+
let su = superuser();
32+
let cat = state.credentials.catalog();
33+
let cat_handle = cat.as_ref().expect("catalog");
34+
35+
// CREATE DATABASE → DatabaseCreated, db_id = new id.
36+
ddl_ok(&state, &su, "CREATE DATABASE smoke_a").await;
37+
let smoke_a = cat_handle
38+
.get_database_id_by_name("smoke_a")
39+
.unwrap()
40+
.unwrap();
41+
assert_audit_has(&state, AuditEvent::DatabaseCreated, Some(smoke_a));
42+
43+
// ALTER … RENAME → DatabaseRenamed, db_id = same id.
44+
ddl_ok(&state, &su, "ALTER DATABASE smoke_a RENAME TO smoke_a2").await;
45+
assert_audit_has(&state, AuditEvent::DatabaseRenamed, Some(smoke_a));
46+
47+
// ALTER … SET QUOTA → DatabaseQuotaChanged.
48+
ddl_ok(
49+
&state,
50+
&su,
51+
"ALTER DATABASE smoke_a2 SET QUOTA (max_memory_bytes = 1024)",
52+
)
53+
.await;
54+
assert_audit_has(&state, AuditEvent::DatabaseQuotaChanged, Some(smoke_a));
55+
56+
// ALTER … SET AUDIT_DML → DatabaseAuditDmlChanged.
57+
ddl_ok(
58+
&state,
59+
&su,
60+
"ALTER DATABASE smoke_a2 SET AUDIT_DML = 'writes'",
61+
)
62+
.await;
63+
assert_audit_has(&state, AuditEvent::DatabaseAuditDmlChanged, Some(smoke_a));
64+
65+
// ALTER … SET IDLE_TIMEOUT → DatabaseIdleTimeoutChanged.
66+
ddl_ok(
67+
&state,
68+
&su,
69+
"ALTER DATABASE smoke_a2 SET IDLE_TIMEOUT = 1800",
70+
)
71+
.await;
72+
assert_audit_has(
73+
&state,
74+
AuditEvent::DatabaseIdleTimeoutChanged,
75+
Some(smoke_a),
76+
);
77+
78+
// CLONE DATABASE → DatabaseCloned, db_id = target (new clone) id.
79+
ddl_ok(&state, &su, "CLONE DATABASE smoke_clone FROM smoke_a2").await;
80+
let smoke_clone_id = cat_handle
81+
.get_database_id_by_name("smoke_clone")
82+
.unwrap()
83+
.unwrap();
84+
assert_audit_has(&state, AuditEvent::DatabaseCloned, Some(smoke_clone_id));
85+
86+
// DROP DATABASE → DatabaseDropped, db_id = id of dropped db.
87+
// FORCE because we just cloned from smoke_a2; the dependency check would
88+
// otherwise reject the drop. Both regular and FORCE paths emit the same
89+
// event per the locked gating matrix.
90+
ddl_ok(&state, &su, "DROP DATABASE smoke_a2 FORCE").await;
91+
assert_audit_has(&state, AuditEvent::DatabaseDropped, Some(smoke_a));
92+
}
93+
94+
// ── H.2 Cross-checklist: CLONE without Superuser ────────────────────────────
95+
96+
/// CLONE DATABASE rejected without Superuser (D), accepted with Superuser
97+
/// (A), audit row produced.
98+
#[tokio::test]
99+
async fn clone_database_requires_superuser_audit() {
100+
let state = make_state_with_catalog();
101+
let su = superuser();
102+
ddl_ok(&state, &su, "CREATE DATABASE clone_src").await;
103+
let cat = state.credentials.catalog();
104+
let cat_handle = cat.as_ref().expect("catalog");
105+
let src_id = cat_handle
106+
.get_database_id_by_name("clone_src")
107+
.unwrap()
108+
.unwrap();
109+
110+
// Cluster admin (not Superuser) tries to clone → 42501.
111+
let ca = cluster_admin_user();
112+
let err = ddl_err(&state, &ca, "CLONE DATABASE c1 FROM clone_src").await;
113+
assert!(
114+
err.contains("42501") || err.contains("permission denied"),
115+
"expected 42501, got: {err}"
116+
);
117+
// PermissionDenied audit row tagged with source db_id.
118+
assert_audit_has(&state, AuditEvent::PermissionDenied, Some(src_id));
119+
120+
// Readonly user → also denied.
121+
let viewer = readonly_user();
122+
let err = ddl_err(&state, &viewer, "CLONE DATABASE c2 FROM clone_src").await;
123+
assert!(
124+
err.contains("42501") || err.contains("permission denied"),
125+
"expected 42501, got: {err}"
126+
);
127+
128+
// Superuser succeeds; audit row uses target (new clone) id.
129+
ddl_ok(&state, &su, "CLONE DATABASE c3 FROM clone_src").await;
130+
let c3_id = cat_handle.get_database_id_by_name("c3").unwrap().unwrap();
131+
assert_audit_has(&state, AuditEvent::DatabaseCloned, Some(c3_id));
132+
}
133+
134+
// ── H.3 Idle-timeout cache observable after ALTER ───────────────────────────
135+
136+
/// `ALTER DATABASE SET IDLE_TIMEOUT` updates the in-memory cache observable
137+
/// by the idle-sweep loop (integration contract).
138+
#[tokio::test]
139+
async fn idle_timeout_cache_observable_after_alter() {
140+
let state = make_state_with_catalog();
141+
let su = superuser();
142+
143+
ddl_ok(&state, &su, "ALTER DATABASE default SET IDLE_TIMEOUT = 600").await;
144+
assert_eq!(state.idle_timeout_cache.get(DatabaseId::DEFAULT), 600);
145+
146+
// Setting back to 0 must remove the per-database override.
147+
ddl_ok(&state, &su, "ALTER DATABASE default SET IDLE_TIMEOUT = 0").await;
148+
assert_eq!(state.idle_timeout_cache.get(DatabaseId::DEFAULT), 0);
149+
}

0 commit comments

Comments
 (0)