Skip to content

Commit ec34eb8

Browse files
committed
security: harden database-scoped event processing
1 parent 80b546c commit ec34eb8

254 files changed

Lines changed: 11970 additions & 3644 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

nodedb-cluster-tests/tests/descriptor_lease_cross_node.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const WAIT_BUDGET: Duration = Duration::from_secs(2);
2121
const POLL: Duration = Duration::from_millis(20);
2222

2323
fn coll_id(name: &str) -> DescriptorId {
24-
DescriptorId::new(TENANT, DescriptorKind::Collection, name.to_string())
24+
DescriptorId::new(0, TENANT, DescriptorKind::Collection, name.to_string())
2525
}
2626

2727
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]

nodedb-cluster-tests/tests/descriptor_lease_drain.rs

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const WAIT_BUDGET: Duration = Duration::from_secs(3);
2727
const POLL: Duration = Duration::from_millis(20);
2828

2929
fn coll_id(name: &str) -> DescriptorId {
30-
DescriptorId::new(TENANT, DescriptorKind::Collection, name.to_string())
30+
DescriptorId::new(0, TENANT, DescriptorKind::Collection, name.to_string())
3131
}
3232

3333
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
@@ -260,28 +260,20 @@ async fn ddl_waits_for_existing_lease_to_release() {
260260
);
261261

262262
wait_for(
263-
"collection stamped v2 on every node",
263+
"collection stamped v2 and drain cleared on every node",
264264
WAIT_BUDGET,
265265
POLL,
266266
|| {
267-
cluster
268-
.nodes
269-
.iter()
270-
.all(|n| n.collection_descriptor(TENANT, "drainable").map(|s| s.0) == Some(2))
267+
cluster.nodes.iter().all(|node| {
268+
node.collection_descriptor(TENANT, "drainable")
269+
.map(|stored| stored.0)
270+
== Some(2)
271+
&& !node.has_drain_for(&coll_id("drainable"), 1)
272+
})
271273
},
272274
)
273275
.await;
274276

275-
// Drain should have cleared on every node (implicit clear on
276-
// successful Put* apply).
277-
for node in &cluster.nodes {
278-
assert!(
279-
!node.has_drain_for(&coll_id("drainable"), 1),
280-
"node {} still has drain entry after DDL committed",
281-
node.node_id
282-
);
283-
}
284-
285277
let _ = shared; // Keep the earlier binding alive to silence warnings.
286278
cluster.shutdown().await;
287279
}

nodedb-cluster-tests/tests/descriptor_lease_planner_integration.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const WAIT_BUDGET: Duration = Duration::from_secs(10);
2121
const POLL: Duration = Duration::from_millis(20);
2222

2323
fn coll_id(name: &str) -> DescriptorId {
24-
DescriptorId::new(TENANT, DescriptorKind::Collection, name.to_string())
24+
DescriptorId::new(0, TENANT, DescriptorKind::Collection, name.to_string())
2525
}
2626

2727
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]

nodedb-cluster-tests/tests/sql_cluster_cross_node_dml_tests/ddl_objects.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use std::time::Duration;
66

77
use crate::common::cluster_harness::{TestCluster, wait_for};
8+
use nodedb_types::DatabaseId;
89

910
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
1011
async fn create_on_any_node_is_visible_on_every_node() {
@@ -289,7 +290,7 @@ async fn change_stream_create_visible_on_every_node() {
289290
cluster
290291
.nodes
291292
.iter()
292-
.all(|n| n.has_change_stream(1, "event_feed"))
293+
.all(|n| n.has_change_stream(DatabaseId::DEFAULT, 1, "event_feed"))
293294
},
294295
)
295296
.await;
@@ -307,7 +308,7 @@ async fn change_stream_create_visible_on_every_node() {
307308
cluster
308309
.nodes
309310
.iter()
310-
.all(|n| !n.has_change_stream(1, "event_feed"))
311+
.all(|n| !n.has_change_stream(DatabaseId::DEFAULT, 1, "event_feed"))
311312
},
312313
)
313314
.await;

nodedb-cluster/src/metadata_group/descriptors/common.rs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ use serde::{Deserialize, Serialize};
77

88
use crate::metadata_group::state::DescriptorState;
99

10-
/// Globally unique, tenant-scoped identifier for a schema descriptor.
10+
/// Globally unique, database- and tenant-scoped identifier for a schema descriptor.
1111
///
12-
/// `kind` disambiguates object types sharing the same `(tenant_id, name)`
13-
/// (e.g. a collection and an index can both be named `orders`).
12+
/// `kind` disambiguates object types sharing the same
13+
/// `(database_id, tenant_id, name)` (e.g. a collection and an index can both
14+
/// be named `orders`).
1415
#[derive(
1516
Debug,
1617
Clone,
@@ -23,21 +24,51 @@ use crate::metadata_group::state::DescriptorState;
2324
zerompk::FromMessagePack,
2425
)]
2526
pub struct DescriptorId {
27+
pub database_id: u64,
2628
pub tenant_id: u64,
2729
pub kind: DescriptorKind,
2830
pub name: String,
2931
}
3032

3133
impl DescriptorId {
32-
pub fn new(tenant_id: u64, kind: DescriptorKind, name: impl Into<String>) -> Self {
34+
pub fn new(
35+
database_id: u64,
36+
tenant_id: u64,
37+
kind: DescriptorKind,
38+
name: impl Into<String>,
39+
) -> Self {
3340
Self {
41+
database_id,
3442
tenant_id,
3543
kind,
3644
name: name.into(),
3745
}
3846
}
3947
}
4048

49+
#[cfg(test)]
50+
mod tests {
51+
use super::*;
52+
use std::collections::HashSet;
53+
54+
#[test]
55+
fn database_id_participates_in_equality_hash_and_wire_format() {
56+
let alpha = DescriptorId::new(7, 1, DescriptorKind::Collection, "orders");
57+
let beta = DescriptorId::new(8, 1, DescriptorKind::Collection, "orders");
58+
59+
assert_ne!(alpha, beta);
60+
let mut ids = HashSet::new();
61+
ids.insert(alpha.clone());
62+
ids.insert(beta.clone());
63+
assert_eq!(ids.len(), 2);
64+
65+
let encoded = zerompk::to_msgpack_vec(&alpha).expect("encode descriptor id");
66+
let decoded: DescriptorId = zerompk::from_msgpack(&encoded).expect("decode descriptor id");
67+
assert_eq!(decoded, alpha);
68+
assert_eq!(decoded.database_id, 7);
69+
}
70+
}
71+
4172
/// Discriminant for [`DescriptorId`] — one variant per schema object type.
4273
#[derive(
4374
Debug,

nodedb-physical/src/physical_plan/cluster_event.rs

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
//! These plans are cluster-RPC envelopes only. They must never cross the
66
//! Control Plane → Data Plane bridge.
77
8+
use nodedb_types::DatabaseId;
9+
10+
/// Hard cap for committed CDC cursors supplied in one cluster consume request.
11+
///
12+
/// This bounds a caller-controlled wire vector while still allowing one cursor
13+
/// for every partition in a reasonably sized routed stream.
14+
pub const MAX_REMOTE_CDC_COMMITTED_OFFSETS: usize = 4_096;
15+
816
/// Cluster-routed Event-Plane operation.
917
#[derive(
1018
Debug,
@@ -16,29 +24,42 @@
1624
zerompk::FromMessagePack,
1725
)]
1826
pub enum ClusterEventOp {
19-
/// Consume one CDC partition from the leader node's local event buffer.
27+
/// Consume CDC events from the leader node's local event buffer.
28+
///
29+
/// `committed_offsets` belongs to the caller node: each tuple is
30+
/// `(partition_id, lsn, sequence)`. The receiver must use these cursors
31+
/// rather than its own consumer-group offset store. Missing partitions
32+
/// start at the initial `(0, 0)` position.
2033
ConsumeStream {
34+
database_id: DatabaseId,
2135
stream_name: String,
2236
group_name: String,
23-
partition: u32,
37+
partition: Option<u32>,
2438
limit: u64,
39+
committed_offsets: Vec<(u32, u64, u64)>,
2540
},
2641
/// Publish one durable-topic message on the topic's home node.
27-
PublishTopic { topic_name: String, payload: String },
42+
PublishTopic {
43+
database_id: DatabaseId,
44+
topic_name: String,
45+
payload: String,
46+
},
2847
}
2948

3049
#[cfg(test)]
3150
mod tests {
32-
use super::ClusterEventOp;
51+
use super::{ClusterEventOp, DatabaseId};
3352
use crate::physical_plan::{PhysicalPlan, wire};
3453

3554
#[test]
3655
fn cluster_event_plan_roundtrips_over_cluster_wire() {
3756
let plan = PhysicalPlan::ClusterEvent(ClusterEventOp::ConsumeStream {
57+
database_id: DatabaseId::new(7),
3858
stream_name: "orders; no SQL".into(),
3959
group_name: "Analytics".into(),
40-
partition: 7,
60+
partition: Some(7),
4161
limit: 128,
62+
committed_offsets: vec![(7, 42, 3)],
4263
});
4364
let encoded = wire::encode(&plan).expect("encode typed cluster event");
4465
assert_eq!(

nodedb-physical/src/physical_plan/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ pub mod wire;
2727

2828
pub use array::{ArrayBinaryOp, ArrayOp, ArrayReducer};
2929
pub use cluster_array::ClusterArrayOp;
30-
pub use cluster_event::ClusterEventOp;
30+
pub use cluster_event::{ClusterEventOp, MAX_REMOTE_CDC_COMMITTED_OFFSETS};
3131
pub use columnar::{ColumnarInsertIntent, ColumnarOp};
3232
pub use crdt::CrdtOp;
3333
pub use document::{

nodedb-test-support/src/cluster_harness/node/inspect/catalog.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,16 @@ impl TestClusterNode {
8585

8686
/// Check whether a change stream with the given name exists in
8787
/// this node's in-memory `stream_registry`.
88-
pub fn has_change_stream(&self, tenant_id: u64, name: &str) -> bool {
89-
self.shared.stream_registry.get(tenant_id, name).is_some()
88+
pub fn has_change_stream(
89+
&self,
90+
database_id: nodedb_types::DatabaseId,
91+
tenant_id: u64,
92+
name: &str,
93+
) -> bool {
94+
self.shared
95+
.stream_registry
96+
.get(database_id, tenant_id, name)
97+
.is_some()
9098
}
9199

92100
/// Check whether a user exists and is active in this node's

nodedb-test-support/src/cluster_harness/node/inspect/lease.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ impl TestClusterNode {
5959
min_version: u64,
6060
) -> bool {
6161
let now = self.shared.hlc_clock.peek();
62-
let id = nodedb_cluster::DescriptorId::new(tenant_id, kind, name);
62+
let id = nodedb_cluster::DescriptorId::new(
63+
nodedb_types::DatabaseId::DEFAULT.as_u64(),
64+
tenant_id,
65+
kind,
66+
name,
67+
);
6368
let cache = self
6469
.shared
6570
.metadata_cache
@@ -79,7 +84,12 @@ impl TestClusterNode {
7984
tenant_id: u64,
8085
name: &str,
8186
) -> Vec<nodedb_cluster::DescriptorLease> {
82-
let id = nodedb_cluster::DescriptorId::new(tenant_id, kind, name);
87+
let id = nodedb_cluster::DescriptorId::new(
88+
nodedb_types::DatabaseId::DEFAULT.as_u64(),
89+
tenant_id,
90+
kind,
91+
name,
92+
);
8393
let cache = self
8494
.shared
8595
.metadata_cache
@@ -107,7 +117,12 @@ impl TestClusterNode {
107117
version: u64,
108118
duration: std::time::Duration,
109119
) -> Result<nodedb_cluster::DescriptorLease, String> {
110-
let id = nodedb_cluster::DescriptorId::new(tenant_id, kind, name.to_string());
120+
let id = nodedb_cluster::DescriptorId::new(
121+
nodedb_types::DatabaseId::DEFAULT.as_u64(),
122+
tenant_id,
123+
kind,
124+
name.to_string(),
125+
);
111126
self.shared
112127
.acquire_descriptor_lease(id, version, duration)
113128
.map_err(|e| format!("acquire failed: {e}"))

nodedb-test-support/src/cluster_harness/node/lifecycle/spawn_full.rs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use nodedb::config::auth::AuthMode;
1616
use nodedb::config::server::ClusterSettings;
1717
use nodedb::control::server::pgwire::listener::PgListener;
1818
use nodedb::control::state::SharedState;
19-
use nodedb::event::{EventPlane, create_event_bus};
19+
use nodedb::event::{EventPlane, EventPlaneConfig, create_event_bus};
2020
use nodedb::wal::WalManager;
2121

2222
use crate::cluster_harness::cluster::ClusterSpawnConfig;
@@ -270,15 +270,18 @@ impl TestClusterNode {
270270
let trigger_dlq = Arc::new(std::sync::Mutex::new(
271271
nodedb::event::trigger::TriggerDlq::open(&data_dir_path)?,
272272
));
273-
let event_plane = EventPlane::spawn(
274-
event_consumers,
275-
Arc::clone(&wal),
273+
let (pg_shutdown_bus, _) =
274+
nodedb::control::shutdown::ShutdownBus::new(Arc::clone(&shared.shutdown));
275+
let event_plane = EventPlane::spawn(EventPlaneConfig {
276+
consumers_rx: event_consumers,
277+
wal: Arc::clone(&wal),
276278
watermark_store,
277-
Arc::clone(&shared),
279+
shared_state: Arc::clone(&shared),
278280
trigger_dlq,
279-
Arc::clone(&shared.cdc_router),
280-
Arc::clone(&shared.shutdown),
281-
);
281+
cdc_router: Arc::clone(&shared.cdc_router),
282+
shutdown: Arc::clone(&shared.shutdown),
283+
shutdown_bus: pg_shutdown_bus.clone(),
284+
});
282285

283286
// Start Raft + install MetadataCommitApplier.
284287
let (cluster_shutdown_tx, cluster_shutdown_rx) = tokio::sync::watch::channel(false);
@@ -339,8 +342,6 @@ impl TestClusterNode {
339342
// accepts immediately without a startup-phase delay.
340343
let pg_listener = PgListener::bind("127.0.0.1:0".parse()?).await?;
341344
let pg_addr = pg_listener.local_addr();
342-
let (pg_shutdown_bus, _) =
343-
nodedb::control::shutdown::ShutdownBus::new(Arc::clone(&shared.shutdown));
344345
let shared_pg = Arc::clone(&shared);
345346
let test_startup_gate = Arc::clone(&shared.startup);
346347
let bus_pg = pg_shutdown_bus.clone();

0 commit comments

Comments
 (0)