Skip to content

Commit f89f388

Browse files
committed
feat(security): centralize SQL task authorization
1 parent dd911ab commit f89f388

10 files changed

Lines changed: 906 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//! Transport-neutral authorization failures.
2+
3+
use crate::types::TenantId;
4+
5+
/// A safe, transport-neutral authorization denial.
6+
#[derive(Debug, Clone, PartialEq, Eq)]
7+
pub struct AuthorizationError {
8+
tenant_id: TenantId,
9+
resource: String,
10+
}
11+
12+
impl AuthorizationError {
13+
pub fn new(tenant_id: TenantId, resource: impl Into<String>) -> Self {
14+
Self {
15+
tenant_id,
16+
resource: resource.into(),
17+
}
18+
}
19+
20+
pub fn tenant_id(&self) -> TenantId {
21+
self.tenant_id
22+
}
23+
24+
pub fn resource(&self) -> &str {
25+
&self.resource
26+
}
27+
}
28+
29+
impl From<AuthorizationError> for crate::Error {
30+
fn from(error: AuthorizationError) -> Self {
31+
Self::RejectedAuthz {
32+
tenant_id: error.tenant_id,
33+
resource: error.resource,
34+
}
35+
}
36+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//! Protocol-neutral authorization for SQL physical tasks.
2+
3+
pub mod error;
4+
pub mod requirements;
5+
pub mod service;
6+
7+
pub use error::AuthorizationError;
8+
pub use requirements::{AuthorizationRequirement, plan_requirements};
9+
pub use service::{authorize_database, authorize_task_set};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//! Physical-plan authorization requirements.
2+
//!
3+
//! This deliberately does not use `shared::plan_util::extract_collection`: a
4+
//! single collection cannot represent joins or source/target DML correctly.
5+
6+
#![deny(clippy::wildcard_enum_match_arm)]
7+
8+
use crate::control::security::identity::Permission;
9+
10+
mod collect;
11+
mod order;
12+
mod query;
13+
14+
pub use collect::plan_requirements;
15+
16+
/// A protected resource and the permission needed to use it.
17+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18+
pub enum AuthorizationRequirement {
19+
/// A collection-scoped operation. The name is the physical-plan name and
20+
/// may be database-qualified; the authorization service normalizes it to
21+
/// the grant-store name before looking up grants.
22+
Collection {
23+
collection: String,
24+
permission: Permission,
25+
},
26+
/// An operation with no collection-level resource, such as an array or a
27+
/// tenant-wide maintenance action. It must still be authorized at tenant
28+
/// scope rather than silently allowed.
29+
Tenant { permission: Permission },
30+
}
31+
32+
impl AuthorizationRequirement {
33+
fn collection(collection: impl Into<String>, permission: Permission) -> Self {
34+
Self::Collection {
35+
collection: collection.into(),
36+
permission,
37+
}
38+
}
39+
40+
fn tenant(permission: Permission) -> Self {
41+
Self::Tenant { permission }
42+
}
43+
}
44+
45+
#[cfg(test)]
46+
#[path = "requirements/tests.rs"]
47+
mod tests;
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
use crate::bridge::envelope::PhysicalPlan;
2+
use crate::control::gateway::version_set::touched_collections;
3+
use crate::control::security::identity::{Permission, required_permission};
4+
5+
use super::AuthorizationRequirement;
6+
use super::order::requirement_order;
7+
use super::query::collect_query_requirements;
8+
9+
/// Return every collection requirement for `plan`.
10+
///
11+
/// The general plan permission applies to ordinary single-resource plans. The
12+
/// multi-resource cases below intentionally override it so sources require
13+
/// `Read` while targets require `Write`. Nested query inputs are traversed
14+
/// iteratively in addition to their named collection fields.
15+
pub fn plan_requirements(plan: &PhysicalPlan) -> Vec<AuthorizationRequirement> {
16+
let mut requirements = Vec::new();
17+
collect_requirements(plan, &mut requirements);
18+
requirements.sort_by(requirement_order);
19+
requirements.dedup();
20+
requirements
21+
}
22+
23+
fn collect_requirements(plan: &PhysicalPlan, out: &mut Vec<AuthorizationRequirement>) {
24+
use nodedb_physical::physical_plan::{CrdtOp, DocumentOp, GraphOp, KvOp, MetaOp, SpatialOp};
25+
26+
let mut pending = vec![plan];
27+
while let Some(plan) = pending.pop() {
28+
let initial_len = out.len();
29+
match plan {
30+
PhysicalPlan::Document(DocumentOp::InsertSelect {
31+
target_collection,
32+
source_collection,
33+
..
34+
})
35+
| PhysicalPlan::Document(DocumentOp::UpdateFromJoin {
36+
target_collection,
37+
source_collection,
38+
..
39+
})
40+
| PhysicalPlan::Document(DocumentOp::Merge {
41+
target_collection,
42+
source_collection,
43+
..
44+
}) => {
45+
out.push(AuthorizationRequirement::collection(
46+
target_collection,
47+
Permission::Write,
48+
));
49+
out.push(AuthorizationRequirement::collection(
50+
source_collection,
51+
Permission::Read,
52+
));
53+
}
54+
PhysicalPlan::Kv(KvOp::TransferItem {
55+
source_collection,
56+
dest_collection,
57+
..
58+
}) => {
59+
out.push(AuthorizationRequirement::collection(
60+
source_collection,
61+
Permission::Read,
62+
));
63+
out.push(AuthorizationRequirement::collection(
64+
dest_collection,
65+
Permission::Write,
66+
));
67+
}
68+
PhysicalPlan::Query(_) | PhysicalPlan::Vector(_) => {
69+
if !collect_query_requirements(plan, &mut pending, out) {
70+
add_general_requirements(plan, out);
71+
}
72+
}
73+
PhysicalPlan::Spatial(SpatialOp::Insert { collection, .. })
74+
| PhysicalPlan::Spatial(SpatialOp::Delete { collection, .. })
75+
| PhysicalPlan::Crdt(
76+
CrdtOp::ImportSnapshot { collection, .. }
77+
| CrdtOp::SetConstraints { collection, .. }
78+
| CrdtOp::DropConstraints { collection, .. }
79+
| CrdtOp::ReadConstraints { collection, .. }
80+
| CrdtOp::GetVersionVector { collection, .. }
81+
| CrdtOp::ExportDelta { collection, .. }
82+
| CrdtOp::CompactAtVersion { collection, .. },
83+
) => add_collection_requirement(collection, required_permission(plan), out),
84+
PhysicalPlan::Graph(GraphOp::EdgePut { collection, .. })
85+
| PhysicalPlan::Graph(GraphOp::EdgeDelete { collection, .. }) => {
86+
add_collection_requirement(collection, required_permission(plan), out);
87+
}
88+
PhysicalPlan::Graph(GraphOp::EdgePutBatch { edges })
89+
| PhysicalPlan::Graph(GraphOp::EdgeDeleteBatch { edges }) => {
90+
let permission = required_permission(plan);
91+
for edge in edges {
92+
add_collection_requirement(&edge.collection, permission, out);
93+
}
94+
}
95+
PhysicalPlan::Meta(
96+
MetaOp::ConvertCollection { collection, .. }
97+
| MetaOp::EnforceTimeseriesRetention { collection, .. }
98+
| MetaOp::TemporalPurgeEdgeStore { collection, .. }
99+
| MetaOp::TemporalPurgeDocumentStrict { collection, .. }
100+
| MetaOp::TemporalPurgeColumnar { collection, .. }
101+
| MetaOp::TemporalPurgeCrdt { collection, .. }
102+
| MetaOp::QueryLastValues { collection }
103+
| MetaOp::QueryLastValue { collection, .. }
104+
| MetaOp::RebuildIndex { collection, .. },
105+
) => add_collection_requirement(collection, required_permission(plan), out),
106+
PhysicalPlan::Meta(
107+
MetaOp::UnregisterCollection { name, .. }
108+
| MetaOp::UnregisterMaterializedView { name, .. }
109+
| MetaOp::QueryCollectionSize { name, .. },
110+
) => add_collection_requirement(name, required_permission(plan), out),
111+
PhysicalPlan::Meta(MetaOp::RenameCollection {
112+
old_collection,
113+
new_collection,
114+
..
115+
}) => {
116+
let permission = required_permission(plan);
117+
add_collection_requirement(old_collection, permission, out);
118+
add_collection_requirement(new_collection, permission, out);
119+
}
120+
PhysicalPlan::Meta(MetaOp::TransactionBatch { plans, .. })
121+
| PhysicalPlan::Meta(MetaOp::ResolveTxn { plans, .. })
122+
| PhysicalPlan::Meta(MetaOp::RecordCalvinWriteVersions { plans, .. })
123+
| PhysicalPlan::Meta(MetaOp::CalvinExecuteStatic { plans, .. })
124+
| PhysicalPlan::Meta(MetaOp::CalvinExecuteActive { plans, .. }) => {
125+
add_tenant_requirement(required_permission(plan), out);
126+
for nested in plans {
127+
pending.push(nested);
128+
}
129+
}
130+
PhysicalPlan::Meta(MetaOp::StageWrite { plan: nested }) => {
131+
add_tenant_requirement(required_permission(plan), out);
132+
pending.push(nested);
133+
}
134+
PhysicalPlan::Kv(KvOp::Transfer { collection, .. }) => {
135+
out.push(AuthorizationRequirement::collection(
136+
collection,
137+
Permission::Write,
138+
));
139+
}
140+
PhysicalPlan::Kv(
141+
KvOp::Get { .. }
142+
| KvOp::Put { .. }
143+
| KvOp::Insert { .. }
144+
| KvOp::InsertIfAbsent { .. }
145+
| KvOp::InsertOnConflictUpdate { .. }
146+
| KvOp::Delete { .. }
147+
| KvOp::Scan { .. }
148+
| KvOp::Expire { .. }
149+
| KvOp::Persist { .. }
150+
| KvOp::GetTtl { .. }
151+
| KvOp::BatchGet { .. }
152+
| KvOp::BatchPut { .. }
153+
| KvOp::RegisterIndex { .. }
154+
| KvOp::DropIndex { .. }
155+
| KvOp::FieldGet { .. }
156+
| KvOp::FieldSet { .. }
157+
| KvOp::Truncate { .. }
158+
| KvOp::Incr { .. }
159+
| KvOp::IncrFloat { .. }
160+
| KvOp::Cas { .. }
161+
| KvOp::GetSet { .. }
162+
| KvOp::RegisterSortedIndex { .. }
163+
| KvOp::DropSortedIndex { .. }
164+
| KvOp::SortedIndexRank { .. }
165+
| KvOp::SortedIndexTopK { .. }
166+
| KvOp::SortedIndexRange { .. }
167+
| KvOp::SortedIndexCount { .. }
168+
| KvOp::SortedIndexScore { .. }
169+
| KvOp::MaterializeScan { .. },
170+
) => add_general_requirements(plan, out),
171+
PhysicalPlan::Document(_)
172+
| PhysicalPlan::Graph(_)
173+
| PhysicalPlan::Text(_)
174+
| PhysicalPlan::Columnar(_)
175+
| PhysicalPlan::Timeseries(_)
176+
| PhysicalPlan::Spatial(_)
177+
| PhysicalPlan::Crdt(_)
178+
| PhysicalPlan::Meta(_)
179+
| PhysicalPlan::Array(_)
180+
| PhysicalPlan::ClusterArray(_) => add_general_requirements(plan, out),
181+
}
182+
183+
// A plan without a collection name is not public: it still needs the
184+
// permission implied by its physical operation at tenant scope. This also
185+
// covers provider-materialized and array-backed plans.
186+
if out.len() == initial_len {
187+
add_tenant_requirement(required_permission(plan), out);
188+
}
189+
}
190+
}
191+
192+
pub(super) fn add_general_requirements(
193+
plan: &PhysicalPlan,
194+
out: &mut Vec<AuthorizationRequirement>,
195+
) {
196+
let permission = required_permission(plan);
197+
for collection in touched_collections(plan) {
198+
out.push(AuthorizationRequirement::collection(collection, permission));
199+
}
200+
}
201+
202+
pub(super) fn add_collection_requirement(
203+
collection: &str,
204+
permission: Permission,
205+
out: &mut Vec<AuthorizationRequirement>,
206+
) {
207+
if !collection.is_empty() {
208+
out.push(AuthorizationRequirement::collection(collection, permission));
209+
}
210+
}
211+
212+
pub(super) fn add_tenant_requirement(
213+
permission: Permission,
214+
out: &mut Vec<AuthorizationRequirement>,
215+
) {
216+
out.push(AuthorizationRequirement::tenant(permission));
217+
}
218+
219+
pub(super) fn add_read(collection: &str, out: &mut Vec<AuthorizationRequirement>) {
220+
add_collection_requirement(collection, Permission::Read, out);
221+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use crate::control::security::identity::Permission;
2+
3+
use super::AuthorizationRequirement;
4+
5+
pub(super) fn requirement_order(
6+
left: &AuthorizationRequirement,
7+
right: &AuthorizationRequirement,
8+
) -> std::cmp::Ordering {
9+
match (left, right) {
10+
(
11+
AuthorizationRequirement::Tenant { permission: left },
12+
AuthorizationRequirement::Tenant { permission: right },
13+
) => permission_rank(*left).cmp(&permission_rank(*right)),
14+
(AuthorizationRequirement::Tenant { .. }, AuthorizationRequirement::Collection { .. }) => {
15+
std::cmp::Ordering::Less
16+
}
17+
(AuthorizationRequirement::Collection { .. }, AuthorizationRequirement::Tenant { .. }) => {
18+
std::cmp::Ordering::Greater
19+
}
20+
(
21+
AuthorizationRequirement::Collection {
22+
collection: left_collection,
23+
permission: left_permission,
24+
},
25+
AuthorizationRequirement::Collection {
26+
collection: right_collection,
27+
permission: right_permission,
28+
},
29+
) => left_collection.cmp(right_collection).then_with(|| {
30+
permission_rank(*left_permission).cmp(&permission_rank(*right_permission))
31+
}),
32+
}
33+
}
34+
35+
fn permission_rank(permission: Permission) -> u8 {
36+
match permission {
37+
Permission::Read => 0,
38+
Permission::Write => 1,
39+
Permission::Create => 2,
40+
Permission::Drop => 3,
41+
Permission::Alter => 4,
42+
Permission::Admin => 5,
43+
Permission::Monitor => 6,
44+
Permission::Execute => 7,
45+
Permission::Backup => 8,
46+
}
47+
}

0 commit comments

Comments
 (0)