Skip to content

Commit 71c6710

Browse files
[table] Support query-auth row filters and column masking at read time
1 parent 009039f commit 71c6710

32 files changed

Lines changed: 3473 additions & 73 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/api/api_request.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,21 @@ impl AlterTableRequest {
167167
}
168168
}
169169

170+
/// Request for auth table query: the projected columns of the query.
171+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172+
pub struct AuthTableQueryRequest {
173+
/// Projected column names; `None` means all columns.
174+
#[serde(skip_serializing_if = "Option::is_none")]
175+
pub select: Option<Vec<String>>,
176+
}
177+
178+
impl AuthTableQueryRequest {
179+
/// Create a new AuthTableQueryRequest.
180+
pub fn new(select: Option<Vec<String>>) -> Self {
181+
Self { select }
182+
}
183+
}
184+
170185
#[cfg(test)]
171186
mod tests {
172187
use super::*;
@@ -193,6 +208,18 @@ mod tests {
193208
assert!(json.contains("\"updates\""));
194209
}
195210

211+
#[test]
212+
fn test_auth_table_query_request_serialization() {
213+
let req = AuthTableQueryRequest::new(Some(vec!["a".to_string(), "b".to_string()]));
214+
assert_eq!(
215+
serde_json::to_string(&req).unwrap(),
216+
r#"{"select":["a","b"]}"#
217+
);
218+
// `None` omits the key entirely (matches the server's optional field).
219+
let req = AuthTableQueryRequest::new(None);
220+
assert_eq!(serde_json::to_string(&req).unwrap(), "{}");
221+
}
222+
196223
#[test]
197224
fn test_rename_table_request_serialization() {
198225
let source = Identifier::new("db1".to_string(), "table1".to_string());

crates/paimon/src/api/api_response.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,10 +427,55 @@ pub struct GetTableTokenResponse {
427427
pub expires_at_millis: Option<i64>,
428428
}
429429

430+
/// Response for auth table query: the per-user row filter and column masking the
431+
/// client must enforce at read time for a `query-auth.enabled` table.
432+
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
433+
#[serde(rename_all = "camelCase")]
434+
pub struct AuthTableQueryResponse {
435+
/// JSON-serialized row-filter predicates, ANDed together. Empty/None = no filter.
436+
pub filter: Option<Vec<String>>,
437+
/// column name -> JSON-serialized masking transform. Empty/None = no masking.
438+
pub column_masking: Option<HashMap<String, String>>,
439+
}
440+
441+
impl AuthTableQueryResponse {
442+
/// True when the server imposes no row filter and no column masking.
443+
pub fn is_unrestricted(&self) -> bool {
444+
self.filter.as_ref().is_none_or(|f| f.is_empty())
445+
&& self.column_masking.as_ref().is_none_or(|m| m.is_empty())
446+
}
447+
}
448+
430449
#[cfg(test)]
431450
mod tests {
432451
use super::*;
433452

453+
#[test]
454+
fn test_auth_table_query_response_deserialization() {
455+
// A restricted grant: pin the exact wire field names. A drift in either
456+
// (`filter` / `columnMasking`) would deserialize to None and silently
457+
// skip authorization, so this test fails closed against that.
458+
let resp: AuthTableQueryResponse =
459+
serde_json::from_str(r#"{"filter":["p0","p1"],"columnMasking":{"ssn":"m0"}}"#).unwrap();
460+
assert_eq!(resp.filter, Some(vec!["p0".to_string(), "p1".to_string()]));
461+
assert_eq!(
462+
resp.column_masking,
463+
Some(HashMap::from([("ssn".to_string(), "m0".to_string())]))
464+
);
465+
assert!(!resp.is_unrestricted());
466+
467+
// The real server sends `{}` for an unrestricted grant (both fields are
468+
// `@JsonInclude(NON_NULL)` in Java); it must parse to an empty grant.
469+
let empty: AuthTableQueryResponse = serde_json::from_str("{}").unwrap();
470+
assert_eq!(empty, AuthTableQueryResponse::default());
471+
assert!(empty.is_unrestricted());
472+
473+
// Present-but-empty collections are also unrestricted.
474+
let blank: AuthTableQueryResponse =
475+
serde_json::from_str(r#"{"filter":[],"columnMasking":{}}"#).unwrap();
476+
assert!(blank.is_unrestricted());
477+
}
478+
434479
#[test]
435480
fn test_error_response_serialization() {
436481
let resp = ErrorResponse::new(

crates/paimon/src/api/mod.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,16 @@ mod api_response;
3131

3232
// Re-export request types
3333
pub use api_request::{
34-
AlterDatabaseRequest, AlterTableRequest, CreateDatabaseRequest, CreateFunctionRequest,
35-
CreateTableRequest, CreateViewRequest, RenameTableRequest,
34+
AlterDatabaseRequest, AlterTableRequest, AuthTableQueryRequest, CreateDatabaseRequest,
35+
CreateFunctionRequest, CreateTableRequest, CreateViewRequest, RenameTableRequest,
3636
};
3737

3838
// Re-export response types
3939
pub use api_response::{
40-
AuditRESTResponse, ConfigResponse, ErrorResponse, GetDatabaseResponse, GetFunctionResponse,
41-
GetTableResponse, GetTableTokenResponse, GetViewResponse, ListDatabasesResponse,
42-
ListFunctionsResponse, ListPartitionsResponse, ListTablesResponse, ListViewsResponse,
43-
PagedList,
40+
AuditRESTResponse, AuthTableQueryResponse, ConfigResponse, ErrorResponse, GetDatabaseResponse,
41+
GetFunctionResponse, GetTableResponse, GetTableTokenResponse, GetViewResponse,
42+
ListDatabasesResponse, ListFunctionsResponse, ListPartitionsResponse, ListTablesResponse,
43+
ListViewsResponse, PagedList,
4444
};
4545

4646
// Re-export error types

crates/paimon/src/api/resource_paths.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ impl ResourcePaths {
147147
)
148148
}
149149

150+
/// Get the auth-table-query endpoint path (`.../tables/{table}/auth`).
151+
pub fn auth_table(&self, database_name: &str, table_name: &str) -> String {
152+
format!("{}/auth", self.table(database_name, table_name))
153+
}
154+
150155
/// Get the table details endpoint path.
151156
pub fn table_details(&self, database_name: &str) -> String {
152157
format!(
@@ -242,6 +247,15 @@ mod tests {
242247
assert!(table_path.contains("my-table"));
243248
}
244249

250+
#[test]
251+
fn test_auth_table_path_encodes_names() {
252+
let paths = ResourcePaths::new("catalog");
253+
assert_eq!(
254+
paths.auth_table("analytics db", "user events"),
255+
"/v1/catalog/databases/analytics+db/tables/user+events/auth"
256+
);
257+
}
258+
245259
#[test]
246260
fn test_config_path() {
247261
assert_eq!(ResourcePaths::config(), "/v1/config");

0 commit comments

Comments
 (0)