Skip to content

Commit 42dee01

Browse files
[table] Support query-auth row filters and column masking at read time
1 parent bcd9f29 commit 42dee01

21 files changed

Lines changed: 2403 additions & 34 deletions

crates/integrations/datafusion/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ uuid = { version = "1", features = ["v4"] }
4949
[dev-dependencies]
5050
arrow-array = { workspace = true }
5151
arrow-schema = { workspace = true }
52+
# for the shared REST catalog mock (crates/paimon/tests/mock_server.rs)
53+
axum = { version = "0.7", features = ["macros", "tokio", "http1", "http2"] }
5254
flate2 = "1"
5355
parquet = { workspace = true }
5456
tar = "0.4"

crates/integrations/datafusion/src/table/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,11 @@ impl TableProvider for PaimonTableProvider {
377377
.map_err(to_datafusion_error)?;
378378

379379
let target = state.config_options().execution.target_partitions;
380+
// Inexact plan row counts (a query-auth row filter drops rows inside
381+
// `TableRead`) would let DataFusion's aggregate-statistics rule answer
382+
// COUNT(*) with the unfiltered count without ever invoking the read.
380383
let filter_exact = !filter_analysis.requires_residual
384+
&& plan.row_counts_exact()
381385
&& filter_analysis
382386
.pushed_predicate
383387
.as_ref()

crates/integrations/datafusion/src/variant_pushdown.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ impl ExtensionPlanner for VariantExtractionExtensionPlanner {
235235
.collect()
236236
};
237237
let filter_exact = !filter_analysis.requires_residual
238+
&& plan.row_counts_exact()
238239
&& filter_analysis
239240
.pushed_predicate
240241
.as_ref()
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
//! Query-auth enforcement through the DataFusion provider: SQL over a REST
19+
//! catalog table with `query-auth.enabled` must apply the per-user grant
20+
//! (row filter + column masking) fetched at scan-plan time, and COUNT(*)
21+
//! must not shortcut to unfiltered statistics.
22+
//!
23+
//! This is the same provider path the Python binding
24+
//! (`pypaimon_rust.datafusion`) drives via FFI.
25+
26+
use std::collections::HashMap;
27+
use std::sync::Arc;
28+
29+
use datafusion::arrow::array::{Int32Array, Int64Array, StringArray};
30+
use datafusion::arrow::datatypes::{
31+
DataType as ArrowDataType, Field as ArrowField, Schema as ArrowSchema,
32+
};
33+
use datafusion::arrow::record_batch::RecordBatch;
34+
use paimon::api::{AuthTableQueryResponse, ConfigResponse};
35+
use paimon::catalog::{Identifier, RESTCatalog};
36+
use paimon::spec::{BigIntType, DataType, IntType, Schema, VarCharType};
37+
use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options, Table};
38+
use paimon_datafusion::SQLContext;
39+
40+
// Shared REST catalog mock (same source the paimon crate's integration tests
41+
// use); only a subset of its helpers is exercised here.
42+
#[allow(dead_code)]
43+
#[path = "../../../paimon/tests/mock_server.rs"]
44+
mod mock_server;
45+
use mock_server::start_mock_server;
46+
47+
async fn write_batch(table: &Table, batch: RecordBatch, commit_user: &str) {
48+
let write_builder = table
49+
.new_write_builder()
50+
.with_commit_user(commit_user)
51+
.expect("valid commit user");
52+
let mut write = write_builder.new_write().expect("create writer");
53+
write.write_arrow_batch(&batch).await.expect("write batch");
54+
let messages = write.prepare_commit().await.expect("prepare commit");
55+
write_builder
56+
.new_commit()
57+
.commit(messages)
58+
.await
59+
.expect("commit batch");
60+
}
61+
62+
// Multi-threaded: the provider's `block_on_with_runtime` bridges park the
63+
// current thread, which must not be the only thread serving the mock server.
64+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
65+
async fn test_query_auth_grant_enforced_via_sql() {
66+
let tmp = tempfile::tempdir().unwrap();
67+
let warehouse = format!("file://{}", tmp.path().display());
68+
69+
// Write demo_employees (id, name, salary) through a plain FileSystemCatalog.
70+
let mut fs_options = Options::new();
71+
fs_options.set(CatalogOptions::WAREHOUSE, &warehouse);
72+
let fs_catalog = FileSystemCatalog::new(fs_options).expect("create filesystem catalog");
73+
fs_catalog
74+
.create_database("default", true, HashMap::new())
75+
.await
76+
.unwrap();
77+
let columns = || {
78+
Schema::builder()
79+
.column("id", DataType::Int(IntType::new()))
80+
.column("name", DataType::VarChar(VarCharType::new(255).unwrap()))
81+
.column("salary", DataType::BigInt(BigIntType::new()))
82+
.option("bucket", "1")
83+
.option("bucket-key", "id")
84+
};
85+
let identifier = Identifier::new("default", "qa_emp");
86+
fs_catalog
87+
.create_table(&identifier, columns().build().unwrap(), false)
88+
.await
89+
.unwrap();
90+
let writer = fs_catalog.get_table(&identifier).await.unwrap();
91+
let arrow_schema = Arc::new(ArrowSchema::new(vec![
92+
ArrowField::new("id", ArrowDataType::Int32, true),
93+
ArrowField::new("name", ArrowDataType::Utf8, true),
94+
ArrowField::new("salary", ArrowDataType::Int64, true),
95+
]));
96+
let batch = RecordBatch::try_new(
97+
arrow_schema,
98+
vec![
99+
Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5])),
100+
Arc::new(StringArray::from(vec![
101+
"alice", "bob", "charlie", "diana", "eve",
102+
])),
103+
Arc::new(Int64Array::from(vec![120000, 85000, 95000, 70000, 99000])),
104+
],
105+
)
106+
.unwrap();
107+
write_batch(&writer, batch, "u1").await;
108+
109+
// Serve the same files through a mock REST catalog whose grant applies a
110+
// row filter (salary >= 90000) and masks name -> UPPER(name).
111+
let mut defaults = HashMap::new();
112+
defaults.insert("prefix".to_string(), "mock-test".to_string());
113+
let server = start_mock_server(
114+
"test_warehouse".to_string(),
115+
"/tmp/test_warehouse".to_string(),
116+
ConfigResponse::new(defaults),
117+
vec!["default".to_string()],
118+
)
119+
.await;
120+
server.add_table_with_schema(
121+
"default",
122+
"qa_emp",
123+
columns()
124+
.option("query-auth.enabled", "true")
125+
.build()
126+
.unwrap(),
127+
&format!("{warehouse}/default.db/qa_emp"),
128+
);
129+
server.set_auth_response(
130+
"default",
131+
"qa_emp",
132+
AuthTableQueryResponse {
133+
filter: Some(vec![
134+
r#"{"kind":"LEAF","transform":{"name":"FIELD_REF","fieldRef":{"index":2,"name":"salary","type":"BIGINT"}},"function":"GREATER_OR_EQUAL","literals":[90000]}"#
135+
.to_string(),
136+
]),
137+
column_masking: Some(HashMap::from([(
138+
"name".to_string(),
139+
r#"{"name":"UPPER","inputs":[{"index":1,"name":"name","type":"STRING"}]}"#
140+
.to_string(),
141+
)])),
142+
},
143+
);
144+
145+
let mut rest_options = Options::new();
146+
rest_options.set("uri", server.url().expect("server url"));
147+
rest_options.set("warehouse", "test_warehouse");
148+
rest_options.set("token.provider", "bear");
149+
rest_options.set("token", "test_token");
150+
let rest_catalog = RESTCatalog::new(rest_options, true)
151+
.await
152+
.expect("create REST catalog");
153+
154+
// Sanity: the mock-served table must resolve through the Catalog trait.
155+
rest_catalog
156+
.get_table(&identifier)
157+
.await
158+
.expect("REST get_table(default.qa_emp)");
159+
160+
let mut ctx = SQLContext::new();
161+
ctx.register_catalog("paimon", Arc::new(rest_catalog))
162+
.await
163+
.expect("register catalog");
164+
165+
// Row filter + masking must both be applied on the SQL result.
166+
let batches = ctx
167+
.sql("SELECT id, name, salary FROM paimon.default.qa_emp ORDER BY id")
168+
.await
169+
.expect("plan select")
170+
.collect()
171+
.await
172+
.expect("execute select");
173+
let mut rows = Vec::new();
174+
for b in &batches {
175+
let ids = b.column(0).as_any().downcast_ref::<Int32Array>().unwrap();
176+
let names = b.column(1).as_any().downcast_ref::<StringArray>().unwrap();
177+
let sal = b.column(2).as_any().downcast_ref::<Int64Array>().unwrap();
178+
for r in 0..b.num_rows() {
179+
rows.push((ids.value(r), names.value(r).to_string(), sal.value(r)));
180+
}
181+
}
182+
assert_eq!(
183+
rows,
184+
vec![
185+
(1, "ALICE".to_string(), 120000),
186+
(3, "CHARLIE".to_string(), 95000),
187+
(5, "EVE".to_string(), 99000),
188+
],
189+
"grant must drop salary<90000 rows and uppercase name"
190+
);
191+
192+
// COUNT(*) must reflect the filtered row count, not unfiltered statistics
193+
// (the aggregate-statistics optimization must be disabled by the inexact
194+
// plan row counts).
195+
let batches = ctx
196+
.sql("SELECT COUNT(*) FROM paimon.default.qa_emp")
197+
.await
198+
.expect("plan count")
199+
.collect()
200+
.await
201+
.expect("execute count");
202+
let count = batches[0]
203+
.column(0)
204+
.as_any()
205+
.downcast_ref::<Int64Array>()
206+
.unwrap()
207+
.value(0);
208+
assert_eq!(count, 3, "COUNT(*) must not use unfiltered statistics");
209+
}

crates/paimon/src/arrow/format/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ mod avro;
1919
pub(crate) mod blob;
2020
mod mosaic;
2121
mod orc;
22-
mod parquet;
22+
pub(crate) mod parquet;
2323
mod row;
2424
mod shredding;
2525
#[cfg(feature = "vortex")]

crates/paimon/src/arrow/residual.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,7 @@ fn evaluate_set_membership_predicate(
566566
Ok(combined)
567567
}
568568

569-
fn evaluate_column_predicate(
569+
pub(crate) fn evaluate_column_predicate(
570570
column: &ArrayRef,
571571
scalar: &Scalar<ArrayRef>,
572572
op: PredicateOperator,
@@ -726,7 +726,7 @@ fn combine_filter_masks(left: &BooleanArray, right: &BooleanArray, use_or: bool)
726726
})
727727
}
728728

729-
fn boolean_mask_from_predicate(
729+
pub(crate) fn boolean_mask_from_predicate(
730730
len: usize,
731731
mut predicate: impl FnMut(usize) -> bool,
732732
) -> BooleanArray {

crates/paimon/src/table/format_read_builder.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@
1818
//! Read builder for Java-compatible `type=format-table` metadata.
1919
2020
use super::partition_filter::PartitionFilter;
21+
use super::query_auth;
2122
use super::read_builder::resolve_projected_fields;
2223
use super::read_builder::split_scan_predicates;
2324
use super::{Table, TableRead, TableScan};
24-
use crate::spec::{CoreOptions, DataField, Predicate};
25+
use crate::spec::{DataField, Predicate};
2526
use crate::table::source::RowRange;
2627
use crate::Result;
28+
use std::collections::HashSet;
2729

2830
#[derive(Debug, Clone)]
2931
pub(crate) struct FormatReadBuilder<'a> {
@@ -32,6 +34,7 @@ pub(crate) struct FormatReadBuilder<'a> {
3234
partition_filter: Option<PartitionFilter>,
3335
data_predicates: Vec<Predicate>,
3436
limit: Option<usize>,
37+
filter_columns: HashSet<usize>,
3538
}
3639

3740
impl<'a> FormatReadBuilder<'a> {
@@ -42,6 +45,7 @@ impl<'a> FormatReadBuilder<'a> {
4245
partition_filter: None,
4346
data_predicates: Vec::new(),
4447
limit: None,
48+
filter_columns: HashSet::new(),
4549
}
4650
}
4751

@@ -61,6 +65,10 @@ impl<'a> FormatReadBuilder<'a> {
6165
}
6266

6367
pub(crate) fn with_filter(&mut self, filter: Predicate) -> &mut Self {
68+
// Capture the full predicate's columns before it is split, so masked and
69+
// out-of-scope partition keys can't prune on their raw value.
70+
self.filter_columns.clear();
71+
filter.collect_leaf_field_indices(&mut self.filter_columns);
6472
let (partition_predicate, data_predicates) = split_scan_predicates(self.table, filter);
6573
self.partition_filter = partition_predicate.map(|pred| {
6674
PartitionFilter::from_predicate(pred, &self.table.schema().partition_fields())
@@ -91,10 +99,32 @@ impl<'a> FormatReadBuilder<'a> {
9199
self.limit,
92100
None,
93101
)
102+
.with_query_auth_scope(self.filter_columns.clone(), self.projected_schema_indices())
103+
}
104+
105+
/// Table-schema indices of the projected columns (`None` = all).
106+
fn projected_schema_indices(&self) -> Option<Vec<usize>> {
107+
self.read_type.as_ref().map(|fields| {
108+
fields
109+
.iter()
110+
.filter_map(|f| {
111+
self.table
112+
.schema()
113+
.fields()
114+
.iter()
115+
.position(|s| s.id() == f.id())
116+
})
117+
.collect()
118+
})
94119
}
95120

96121
pub(crate) fn new_read(&self) -> Result<TableRead<'a>> {
97-
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
122+
self.table.ensure_read_allowed()?;
123+
query_auth::scope_check(
124+
self.table,
125+
&self.filter_columns,
126+
self.projected_schema_indices(),
127+
)?;
98128
let read_type = match &self.read_type {
99129
None => self.table.schema().fields().to_vec(),
100130
Some(fields) => fields.clone(),

crates/paimon/src/table/format_table_read.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use super::data_file_reader::DataFileReader;
2121
use super::read_builder::split_scan_predicates;
2222
use super::{ArrowRecordBatchStream, Table};
2323
use crate::arrow::{build_target_arrow_schema, paimon_type_to_arrow};
24-
use crate::spec::{extract_datum, BinaryRow, CoreOptions, DataField, DataType, Datum, Predicate};
24+
use crate::spec::{extract_datum, BinaryRow, DataField, DataType, Datum, Predicate};
2525
use crate::{DataSplit, Error};
2626
use arrow_array::{
2727
new_null_array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Float32Array, Float64Array,
@@ -68,6 +68,10 @@ impl<'a> FormatTableRead<'a> {
6868
self.table
6969
}
7070

71+
pub(crate) fn limit(&self) -> Option<usize> {
72+
self.limit
73+
}
74+
7175
pub(crate) fn with_filter(mut self, filter: Predicate) -> Self {
7276
self.data_predicates = split_scan_predicates(self.table, filter).1;
7377
self
@@ -77,7 +81,9 @@ impl<'a> FormatTableRead<'a> {
7781
&self,
7882
data_splits: &[DataSplit],
7983
) -> crate::Result<ArrowRecordBatchStream> {
80-
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
84+
// Defense in depth: the query-auth row filter is applied by the outer
85+
// `TableRead::to_arrow`.
86+
self.table.ensure_read_allowed()?;
8187
let read_type = self.read_type.clone();
8288
let output_schema = build_target_arrow_schema(&read_type)?;
8389
let partition_keys = self.table.schema().partition_keys().to_vec();

0 commit comments

Comments
 (0)