Skip to content

Commit 8e1a605

Browse files
committed
security(authz): require capabilities at dispatch boundaries
1 parent 1025607 commit 8e1a605

128 files changed

Lines changed: 3949 additions & 1931 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.

.config/nextest.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ retries = { backoff = "fixed", count = 2, delay = "1s" }
5151
# default 30s × 4 = 120s ceiling stays in force for unit tests.
5252
slow-timeout = { period = "30s", terminate-after = 6 }
5353

54+
# Load-sensitive in-process integrations use full Data-Plane runtimes and
55+
# bounded request deadlines. Running them beside the unrestricted unit-test
56+
# fan-out can starve their executor threads long enough to turn a healthy local
57+
# dispatch into DeadlineExceeded. Claim every slot for only these measured
58+
# heavy cases; this preserves real request deadlines rather than weakening them.
59+
# The crash-recovery label test is included because its child server otherwise
60+
# competes with the same in-process graph fan-out during the full suite.
61+
[[profile.default.overrides]]
62+
filter = 'binary(graph_cross_core_bfs) | test(query_stream_returns_all_rows_across_chunks) | test(streaming_select_returns_all_rows_across_chunks) | test(graph_node_labels_survive_kill_9) | test(wal_replay_restart_keeps_governor_accounting_sane) | test(alter_add_column_does_not_stall_writes) | test(reindex_vector_concurrent_p99) | test(count_star_sees_flushed_partitions) | test(timeseries_flush_does_not_over_release_engine_budget) | test(timeseries_governor_reflects_resident_memtable_after_flush)'
63+
test-group = 'load-sensitive'
64+
threads-required = 'num-test-threads'
65+
5466
# WAL-truncation crash tests wait on real wall-clock server behaviour that the
5567
# other real-server tests do not: a checkpoint cycle must fire AND actually
5668
# unlink a sealed segment before the test can crash the server, on top of two
@@ -96,6 +108,7 @@ test-group = 'server-process'
96108

97109
[test-groups]
98110
cluster = { max-threads = 1 }
111+
load-sensitive = { max-threads = 1 }
99112
# Up to 2 real-server tests concurrently. Each picks ephemeral (OS-assigned)
100113
# ports + a private tempdir, so two running side by side never collide on
101114
# ports/files; 2 single-core servers fit any CI runner, and CI `retries`

.github/workflows/test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ jobs:
6868
run: |
6969
python3 scripts/ci/check_reconstructed_sql.py --self-test
7070
python3 scripts/ci/check_reconstructed_sql.py
71+
- name: Authorized-dispatch gate
72+
run: |
73+
python3 scripts/ci/check_authorized_dispatch.py --self-test
74+
python3 scripts/ci/check_authorized_dispatch.py
7175
- name: Install cargo-deny
7276
uses: taiki-e/install-action@v2
7377
with:

nodedb-cluster-tests/tests/array_raft_partition_recovery.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,19 @@ fn make_inbound(shared: &Arc<SharedState>) -> OriginArrayInbound {
7070
engine,
7171
Arc::clone(&shared.array_sync_schemas),
7272
Arc::clone(shared),
73-
TenantId::new(0),
73+
nodedb::control::security::identity::AuthenticatedIdentity {
74+
user_id: 0,
75+
username: "array-sync-test".into(),
76+
tenant_id: TenantId::new(0),
77+
auth_method: nodedb::control::security::identity::AuthMethod::Trust,
78+
roles: Vec::new(),
79+
is_superuser: true,
80+
default_database: None,
81+
accessible_databases:
82+
nodedb::control::security::identity::AuthenticatedIdentity::default_database_set(
83+
true,
84+
),
85+
},
7486
)
7587
}
7688

nodedb-cluster-tests/tests/array_raft_replication.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,19 @@ fn make_inbound(shared: &Arc<SharedState>) -> OriginArrayInbound {
7979
engine,
8080
Arc::clone(&shared.array_sync_schemas),
8181
Arc::clone(shared),
82-
TenantId::new(0),
82+
nodedb::control::security::identity::AuthenticatedIdentity {
83+
user_id: 0,
84+
username: "array-sync-test".into(),
85+
tenant_id: TenantId::new(0),
86+
auth_method: nodedb::control::security::identity::AuthMethod::Trust,
87+
roles: Vec::new(),
88+
is_superuser: true,
89+
default_database: None,
90+
accessible_databases:
91+
nodedb::control::security::identity::AuthenticatedIdentity::default_database_set(
92+
true,
93+
),
94+
},
8395
)
8496
}
8597

nodedb-cluster-tests/tests/array_raft_snapshot_install.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,19 @@ fn make_inbound(shared: &Arc<SharedState>) -> OriginArrayInbound {
7272
engine,
7373
Arc::clone(&shared.array_sync_schemas),
7474
Arc::clone(shared),
75-
TenantId::new(0),
75+
nodedb::control::security::identity::AuthenticatedIdentity {
76+
user_id: 0,
77+
username: "array-sync-test".into(),
78+
tenant_id: TenantId::new(0),
79+
auth_method: nodedb::control::security::identity::AuthMethod::Trust,
80+
roles: Vec::new(),
81+
is_superuser: true,
82+
default_database: None,
83+
accessible_databases:
84+
nodedb::control::security::identity::AuthenticatedIdentity::default_database_set(
85+
true,
86+
),
87+
},
7688
)
7789
}
7890

nodedb-cluster-tests/tests/array_vshard_migration.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,19 @@ fn make_inbound(shared: &Arc<SharedState>) -> OriginArrayInbound {
7171
engine,
7272
Arc::clone(&shared.array_sync_schemas),
7373
Arc::clone(shared),
74-
TenantId::new(0),
74+
nodedb::control::security::identity::AuthenticatedIdentity {
75+
user_id: 0,
76+
username: "array-sync-test".into(),
77+
tenant_id: TenantId::new(0),
78+
auth_method: nodedb::control::security::identity::AuthMethod::Trust,
79+
roles: Vec::new(),
80+
is_superuser: true,
81+
default_database: None,
82+
accessible_databases:
83+
nodedb::control::security::identity::AuthenticatedIdentity::default_database_set(
84+
true,
85+
),
86+
},
7587
)
7688
}
7789

nodedb-cluster-tests/tests/common/mod.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,48 @@ pub use nodedb_test_support::{
99
array_sync, cluster_harness, make_cdc_event, now_ms, occ_shuffle, pgwire_auth_helpers,
1010
pgwire_harness, tx_batch_helpers,
1111
};
12+
13+
/// Mint the same exact-plan authorization capability required by production
14+
/// gateway entry points, using a trusted superuser identity scoped to `ctx`.
15+
#[allow(dead_code)]
16+
pub fn authorize_gateway_plan(
17+
shared: &nodedb::control::state::SharedState,
18+
ctx: &nodedb::control::gateway::core::QueryContext,
19+
plan: nodedb_physical::physical_plan::PhysicalPlan,
20+
) -> nodedb::control::server::shared::authorization::AuthorizedTask {
21+
use nodedb::control::security::audit::NoopAuditEmitter;
22+
use nodedb::control::security::identity::{AuthMethod, AuthenticatedIdentity};
23+
use nodedb::control::server::shared::authorization::authorize_task_set;
24+
use nodedb_physical::physical_task::{PhysicalTask, PostSetOp};
25+
26+
let identity = AuthenticatedIdentity {
27+
user_id: 0,
28+
username: "cluster-gateway-test".into(),
29+
tenant_id: ctx.tenant_id,
30+
auth_method: AuthMethod::Trust,
31+
roles: Vec::new(),
32+
is_superuser: true,
33+
default_database: Some(ctx.database_id),
34+
accessible_databases: AuthenticatedIdentity::default_database_set(true),
35+
};
36+
let task = PhysicalTask {
37+
tenant_id: ctx.tenant_id,
38+
vshard_id: nodedb::types::VShardId::new(0),
39+
database_id: ctx.database_id,
40+
plan,
41+
post_set_op: PostSetOp::None,
42+
txn_id: ctx.txn_id,
43+
};
44+
authorize_task_set(
45+
&identity,
46+
std::slice::from_ref(&task),
47+
&shared.permissions,
48+
&shared.roles,
49+
&NoopAuditEmitter,
50+
)
51+
.expect("authorize cluster gateway plan")
52+
.into_tasks()
53+
.into_iter()
54+
.next()
55+
.expect("one authorized cluster gateway plan")
56+
}

nodedb-cluster-tests/tests/gateway_execute.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ async fn gateway_execute_kv_put_get_single_node() {
7474
ttl_ms: 0,
7575
surrogate: nodedb_types::Surrogate::ZERO,
7676
});
77-
let put_result = gateway.execute(&ctx, put_plan).await;
77+
let put_authorized = common::authorize_gateway_plan(&node.shared, &ctx, put_plan);
78+
let put_result = gateway.execute(&ctx, put_authorized).await;
7879
assert!(
7980
put_result.is_ok(),
8081
"KvOp::Put failed: {:?}",
@@ -88,7 +89,8 @@ async fn gateway_execute_kv_put_get_single_node() {
8889
rls_filters: vec![],
8990
surrogate_ceiling: None,
9091
});
91-
let get_result = gateway.execute(&ctx, get_plan).await;
92+
let get_authorized = common::authorize_gateway_plan(&node.shared, &ctx, get_plan);
93+
let get_result = gateway.execute(&ctx, get_authorized).await;
9294
assert!(
9395
get_result.is_ok(),
9496
"KvOp::Get failed: {:?}",
@@ -143,7 +145,13 @@ async fn gateway_execute_sql_plan_cache_populated() {
143145

144146
// First call: cache miss — plan_fn is invoked; cache grows to 1.
145147
let _ = gateway
146-
.execute_sql(&ctx, sql, &[], make_plan)
148+
.execute_sql(&ctx, sql, &[], make_plan, |plan| {
149+
Ok(common::authorize_gateway_plan(
150+
&node.shared,
151+
&ctx,
152+
plan.clone(),
153+
))
154+
})
147155
.await
148156
.expect("first execute_sql");
149157

@@ -156,7 +164,13 @@ async fn gateway_execute_sql_plan_cache_populated() {
156164
// Second call with same SQL + same descriptor versions: the actual key is
157165
// identical, so insert is a no-op and len stays 1.
158166
let _ = gateway
159-
.execute_sql(&ctx, sql, &[], make_plan)
167+
.execute_sql(&ctx, sql, &[], make_plan, |plan| {
168+
Ok(common::authorize_gateway_plan(
169+
&node.shared,
170+
&ctx,
171+
plan.clone(),
172+
))
173+
})
160174
.await
161175
.expect("second execute_sql");
162176

nodedb-cluster-tests/tests/http_gateway_migration.rs

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ async fn http_gateway_migration_single_node_query() {
7272
ttl_ms: 0,
7373
surrogate: nodedb_types::Surrogate::ZERO,
7474
});
75-
let put_result = gateway.execute(&ctx, put_plan).await;
75+
let put_authorized = common::authorize_gateway_plan(&node.shared, &ctx, put_plan);
76+
let put_result = gateway.execute(&ctx, put_authorized).await;
7677
assert!(
7778
put_result.is_ok(),
7879
"PUT via gateway failed: {:?}",
@@ -86,7 +87,8 @@ async fn http_gateway_migration_single_node_query() {
8687
rls_filters: vec![],
8788
surrogate_ceiling: None,
8889
});
89-
let get_result = gateway.execute(&ctx, get_plan).await;
90+
let get_authorized = common::authorize_gateway_plan(&node.shared, &ctx, get_plan);
91+
let get_result = gateway.execute(&ctx, get_authorized).await;
9092
assert!(
9193
get_result.is_ok(),
9294
"GET via gateway failed: {:?}",
@@ -141,7 +143,8 @@ async fn http_gateway_migration_cross_node_query() {
141143
ttl_ms: 0,
142144
surrogate: nodedb_types::Surrogate::ZERO,
143145
});
144-
let put_result = gateway.execute(&ctx, put_plan).await;
146+
let put_authorized = common::authorize_gateway_plan(&follower.shared, &ctx, put_plan);
147+
let put_result = gateway.execute(&ctx, put_authorized).await;
145148
assert!(
146149
put_result.is_ok(),
147150
"cross-node PUT via gateway failed: {:?}",
@@ -162,14 +165,26 @@ async fn http_gateway_migration_cross_node_query() {
162165

163166
for i in 0..3u32 {
164167
let result = gateway
165-
.execute_sql(&ctx, get_sql, &[], || {
166-
Ok(PhysicalPlan::Kv(KvOp::Get {
167-
collection: "http_gw_cross_node".into(),
168-
key: b"cross-key".to_vec(),
169-
rls_filters: vec![],
170-
surrogate_ceiling: None,
171-
}))
172-
})
168+
.execute_sql(
169+
&ctx,
170+
get_sql,
171+
&[],
172+
|| {
173+
Ok(PhysicalPlan::Kv(KvOp::Get {
174+
collection: "http_gw_cross_node".into(),
175+
key: b"cross-key".to_vec(),
176+
rls_filters: vec![],
177+
surrogate_ceiling: None,
178+
}))
179+
},
180+
|plan| {
181+
Ok(common::authorize_gateway_plan(
182+
&follower.shared,
183+
&ctx,
184+
plan.clone(),
185+
))
186+
},
187+
)
173188
.await;
174189
assert!(
175190
result.is_ok(),

nodedb-cluster-tests/tests/ilp_gateway_migration.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ async fn ilp_gateway_migration_single_node_ingest() {
7777
surrogates: Vec::new(),
7878
provenance: None,
7979
});
80-
let result = gw.execute(&ctx, plan).await;
80+
let authorized = common::authorize_gateway_plan(&node.shared, &ctx, plan);
81+
let result = gw.execute(&ctx, authorized).await;
8182
assert!(
8283
result.is_ok(),
8384
"gateway ILP ingest failed: {:?}",
@@ -122,7 +123,8 @@ async fn ilp_gateway_migration_cross_node_ingest() {
122123
surrogates: Vec::new(),
123124
provenance: None,
124125
});
125-
let result1 = leader_gw.execute(&ctx, plan1).await;
126+
let authorized1 = common::authorize_gateway_plan(&cluster.nodes[0].shared, &ctx, plan1);
127+
let result1 = leader_gw.execute(&ctx, authorized1).await;
126128
assert!(
127129
result1.is_ok(),
128130
"node 1 (leader) ILP gateway ingest failed: {:?}",
@@ -145,7 +147,8 @@ async fn ilp_gateway_migration_cross_node_ingest() {
145147
});
146148
// Retry once on RetryableSchemaChanged: the descriptor may not yet be in
147149
// the follower catalog when the gateway snapshot was taken.
148-
let result2 = match follower_gw.execute(&ctx, plan2).await {
150+
let authorized2 = common::authorize_gateway_plan(&cluster.nodes[1].shared, &ctx, plan2);
151+
let result2 = match follower_gw.execute(&ctx, authorized2).await {
149152
Err(nodedb::Error::RetryableSchemaChanged { .. }) => {
150153
tokio::time::sleep(Duration::from_millis(150)).await;
151154
let plan2b = PhysicalPlan::Timeseries(TimeseriesOp::Ingest {
@@ -156,7 +159,9 @@ async fn ilp_gateway_migration_cross_node_ingest() {
156159
surrogates: Vec::new(),
157160
provenance: None,
158161
});
159-
follower_gw.execute(&ctx, plan2b).await
162+
let authorized2b =
163+
common::authorize_gateway_plan(&cluster.nodes[1].shared, &ctx, plan2b);
164+
follower_gw.execute(&ctx, authorized2b).await
160165
}
161166
other => other,
162167
};

0 commit comments

Comments
 (0)