This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 2ec58711 feat(auth): apply query-auth row filters on read (#513)
2ec58711 is described below
commit 2ec58711fb8e75553889fb597f22b0444ef8eb15
Author: Jiajia Li <[email protected]>
AuthorDate: Thu Oct 8 14:00:34 2026 +0800
feat(auth): apply query-auth row filters on read (#513)
---
.../datafusion/src/physical_plan/scan.rs | 41 +-
.../datafusion/src/variant_pushdown.rs | 8 +-
crates/integrations/datafusion/tests/query_auth.rs | 374 ++++++++++
crates/paimon/src/table/data_evolution_reader.rs | 17 +
crates/paimon/src/table/managed_blob_reader.rs | 45 ++
crates/paimon/src/table/mod.rs | 13 +-
crates/paimon/src/table/query_auth.rs | 149 +++-
crates/paimon/src/table/query_auth/rules.rs | 699 ++++++++++++++++++
crates/paimon/src/table/source.rs | 37 +
crates/paimon/src/table/table_read.rs | 493 +++++++++++--
crates/paimon/src/table/table_scan.rs | 108 ++-
crates/paimon/tests/mock_server.rs | 16 +-
crates/paimon/tests/rest_catalog_test.rs | 798 ++++++++++++++++++++-
docs/src/sql.md | 17 +
14 files changed, 2699 insertions(+), 116 deletions(-)
diff --git a/crates/integrations/datafusion/src/physical_plan/scan.rs
b/crates/integrations/datafusion/src/physical_plan/scan.rs
index 531c4fb2..4d47ccb1 100644
--- a/crates/integrations/datafusion/src/physical_plan/scan.rs
+++ b/crates/integrations/datafusion/src/physical_plan/scan.rs
@@ -947,6 +947,14 @@ impl PaimonTableScan {
self.limit
}
+ /// Planned under the server's rules: counts and bounds precede them.
+ fn has_query_auth_rules(&self) -> bool {
+ self.planned_partitions
+ .iter()
+ .flat_map(|splits| splits.iter())
+ .any(DataSplit::has_query_auth_rules)
+ }
+
fn manifest_column_statistics(&self, partitions: &[Arc<[DataSplit]>]) ->
Vec<ColumnStatistics> {
if self.read_type.len() != self.schema().fields().len() {
return Statistics::unknown_column(&self.schema());
@@ -1119,9 +1127,12 @@ impl ExecutionPlan for PaimonTableScan {
.into_iter()
.map(|result| result.filter)
.collect::<Vec<_>>();
- if filters.is_empty() {
+ // A query-auth read refuses a decoder filter; the filter above sees
only what it admits.
+ if filters.is_empty()
+ ||
CoreOptions::new(self.table.schema().options()).query_auth_enabled()
+ {
return Ok(FilterPushdownPropagation::with_parent_pushdown_result(
- Vec::new(),
+ vec![PushedDown::No; filters.len()],
));
}
@@ -1182,6 +1193,10 @@ impl ExecutionPlan for PaimonTableScan {
}
fn partition_statistics(&self, partition: Option<usize>) ->
DFResult<Arc<Statistics>> {
+ // They include the rows the server's rules drop.
+ if self.has_query_auth_rules() {
+ return Ok(Arc::new(Statistics::new_unknown(&self.schema())));
+ }
let partitions: &[Arc<[DataSplit]>] = match partition {
Some(idx) => std::slice::from_ref(&self.planned_partitions[idx]),
None => &self.planned_partitions,
@@ -1229,6 +1244,14 @@ impl DisplayAs for PaimonTableScan {
f: &mut std::fmt::Formatter,
) -> std::fmt::Result {
write!(f, "PaimonTableScan: table={}", self.table.identifier())?;
+ let restricted = self.has_query_auth_rules();
+ if restricted {
+ // Split and file counts describe the data before the server's
rules.
+ write!(f, ", query-auth=restricted")?;
+ }
+ // A plan pruned to nothing has no split left to carry the grant.
+ let hidden =
+ restricted ||
CoreOptions::new(self.table.schema().options()).query_auth_enabled();
let total_splits: usize = self.planned_partitions.iter().map(|p|
p.len()).sum();
let total_files: usize = self
@@ -1237,11 +1260,13 @@ impl DisplayAs for PaimonTableScan {
.flat_map(|p| p.iter())
.map(|s| s.data_files().len())
.sum();
- write!(
- f,
- ", partitions={}, splits={total_splits}, files={total_files}",
- self.planned_partitions.len()
- )?;
+ if !hidden {
+ write!(
+ f,
+ ", partitions={}, splits={total_splits}, files={total_files}",
+ self.planned_partitions.len()
+ )?;
+ }
let columns = self
.read_type
@@ -1255,7 +1280,7 @@ impl DisplayAs for PaimonTableScan {
if let Some(limit) = self.limit {
write!(f, ", limit={limit}")?;
}
- if let Some(ref trace) = self.scan_trace {
+ if let Some(trace) = self.scan_trace.as_ref().filter(|_| !hidden) {
write!(f, ", trace={trace}")?;
}
if let Some(ref pushed_variants) = self.pushed_variants {
diff --git a/crates/integrations/datafusion/src/variant_pushdown.rs
b/crates/integrations/datafusion/src/variant_pushdown.rs
index c0a79bf1..6013c504 100644
--- a/crates/integrations/datafusion/src/variant_pushdown.rs
+++ b/crates/integrations/datafusion/src/variant_pushdown.rs
@@ -40,8 +40,8 @@ use datafusion::optimizer::{ApplyOrder, OptimizerConfig,
OptimizerRule};
use datafusion::physical_plan::ExecutionPlan;
use datafusion::physical_planner::{ExtensionPlanner, PhysicalPlanner};
use paimon::spec::{
- variant_extraction_row, BigIntType, BooleanType, DataField, DataType,
DecimalType, DoubleType,
- FloatType, IntType, SmallIntType, TinyIntType, VarCharType,
+ variant_extraction_row, BigIntType, BooleanType, CoreOptions, DataField,
DataType, DecimalType,
+ DoubleType, FloatType, IntType, SmallIntType, TinyIntType, VarCharType,
};
use paimon::table::Table;
@@ -287,6 +287,10 @@ fn build_projection_rewrite(
let Some(provider) = provider.downcast_ref::<PaimonTableProvider>() else {
return Ok(None);
};
+ // Rules run after decoding; above the scan, an extraction sees only the
rows they admit.
+ if
CoreOptions::new(provider.table().schema().options()).query_auth_enabled() {
+ return Ok(None);
+ }
let mut calls = Vec::new();
let mut full_columns = HashSet::new();
diff --git a/crates/integrations/datafusion/tests/query_auth.rs
b/crates/integrations/datafusion/tests/query_auth.rs
new file mode 100644
index 00000000..2ef11688
--- /dev/null
+++ b/crates/integrations/datafusion/tests/query_auth.rs
@@ -0,0 +1,374 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! SQL over a `query-auth.enabled` table whose REST server sets a row filter
+//! for the current user.
+
+mod common;
+
+#[path = "../../../paimon/tests/mock_server.rs"]
+mod mock_server;
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use arrow_array::{Array, Int32Array, Int64Array, RecordBatch, StringArray};
+use arrow_schema::{DataType as ArrowDataType, Field, Schema as ArrowSchema};
+use paimon::api::{AuthTableQueryResponse, ConfigResponse};
+use paimon::catalog::{Catalog, Identifier, RESTCatalog};
+use paimon::spec::{DataType, IntType, Schema, VarCharType, VariantType};
+use paimon::{CatalogOptions, FileSystemCatalog, Options};
+use paimon_datafusion::SQLContext;
+use serde_json::json;
+
+use mock_server::{start_mock_server, RESTServer};
+
+const TABLE: &str = "paimon.default.people";
+
+fn schema(options: &[(&str, &str)]) -> Schema {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("name", DataType::VarChar(VarCharType::new(255).unwrap()))
+ .column("secret", DataType::VarChar(VarCharType::new(255).unwrap()));
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+}
+
+/// Ids 1..=10 written through a filesystem catalog, served by a mock REST
+/// catalog that admits `id > 6` and applies `column_masking`.
+async fn restricted_people(
+ column_masking: Option<HashMap<String, String>>,
+) -> (tempfile::TempDir, RESTServer, SQLContext) {
+ let tmp = tempfile::tempdir().unwrap();
+ let mut fs_options = Options::new();
+ fs_options.set(
+ CatalogOptions::WAREHOUSE,
+ format!("file://{}", tmp.path().display()),
+ );
+ let fs_catalog = FileSystemCatalog::new(fs_options).unwrap();
+ fs_catalog
+ .create_database("default", true, HashMap::new())
+ .await
+ .unwrap();
+ let identifier = Identifier::new("default", "people");
+ fs_catalog
+ .create_table(&identifier, schema(&[]), false)
+ .await
+ .unwrap();
+ let table = fs_catalog.get_table(&identifier).await.unwrap();
+ let names = [
+ "alice", "bob", "carol", "dave", "erin", "frank", "grace", "heidi",
"ivan", "judy",
+ ];
+ let batch = RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ Field::new("id", ArrowDataType::Int32, true),
+ Field::new("name", ArrowDataType::Utf8, true),
+ Field::new("secret", ArrowDataType::Utf8, true),
+ ])),
+ vec![
+ Arc::new(Int32Array::from_iter_values(1..=10)),
+ Arc::new(StringArray::from_iter_values(names)),
+ Arc::new(StringArray::from_iter_values(
+ names.iter().map(|n| format!("{n}-ssn")),
+ )),
+ ],
+ )
+ .unwrap();
+ let write_builder = table.new_write_builder();
+ let mut write = write_builder.new_write().unwrap();
+ write.write_arrow_batch(&batch).await.unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ write_builder.new_commit().commit(messages).await.unwrap();
+
+ let server = start_mock_server(
+ "test_warehouse".to_string(),
+ tmp.path().to_string_lossy().into_owned(),
+ ConfigResponse::new(HashMap::from([(
+ CatalogOptions::PREFIX.to_string(),
+ "mock-test".to_string(),
+ )])),
+ vec!["default".to_string()],
+ )
+ .await;
+ server.add_table_with_schema(
+ "default",
+ "people",
+ schema(&[("query-auth.enabled", "true")]),
+ table.location(),
+ );
+ server.set_auth_response(
+ "default",
+ "people",
+ AuthTableQueryResponse {
+ filter: Some(vec![json!({
+ "kind": "LEAF",
+ "transform": {
+ "name": "FIELD_REF",
+ "fieldRef": {"index": 0, "name": "id", "type": "INT"},
+ },
+ "function": "GREATER_THAN",
+ "literals": [6],
+ })
+ .to_string()]),
+ column_masking,
+ },
+ );
+
+ let mut options = Options::new();
+ options.set(CatalogOptions::URI, server.url().unwrap());
+ options.set(CatalogOptions::WAREHOUSE, "test_warehouse");
+ options.set(CatalogOptions::TOKEN_PROVIDER, "bear");
+ options.set(CatalogOptions::TOKEN, "test-token");
+ let catalog = Arc::new(RESTCatalog::new(options, true).await.unwrap());
+ let mut context = SQLContext::new();
+ context.register_catalog("paimon", catalog).await.unwrap();
+ (tmp, server, context)
+}
+
+async fn query(context: &SQLContext, sql: &str) -> Vec<RecordBatch> {
+ context.sql(sql).await.unwrap().collect().await.unwrap()
+}
+
+fn id_names(batches: &[RecordBatch]) -> Vec<(i32, String)> {
+ let mut rows = Vec::new();
+ for batch in batches {
+ let ids = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ for row in 0..batch.num_rows() {
+ rows.push((
+ ids.value(row),
+ common::string_value(batch.column(1).as_ref(),
row).to_string(),
+ ));
+ }
+ }
+ rows.sort();
+ rows
+}
+
+/// The one value of a one-row, one-column result.
+fn single(batches: &[RecordBatch]) -> Arc<dyn Array> {
+ assert_eq!(
+ batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
+ 1,
+ "{batches:?}"
+ );
+ let batch = batches.iter().find(|b| b.num_rows() == 1).unwrap();
+ assert_eq!(batch.num_columns(), 1);
+ Arc::clone(batch.column(0))
+}
+
+fn count(batches: &[RecordBatch]) -> i64 {
+ single(batches)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap()
+ .value(0)
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_query_auth_queries_see_only_the_rows_the_filter_admits() {
+ let (_tmp, _server, context) = restricted_people(None).await;
+
+ assert_eq!(
+ id_names(&query(&context, &format!("SELECT id, name FROM
{TABLE}")).await),
+ vec![
+ (7, "grace".to_string()),
+ (8, "heidi".to_string()),
+ (9, "ivan".to_string()),
+ (10, "judy".to_string()),
+ ]
+ );
+ // Manifest statistics would answer 10 and 1.
+ assert_eq!(
+ count(&query(&context, &format!("SELECT COUNT(*) FROM
{TABLE}")).await),
+ 4
+ );
+ let min = single(&query(&context, &format!("SELECT MIN(id) FROM
{TABLE}")).await);
+ assert_eq!(
+ min.as_any().downcast_ref::<Int32Array>().unwrap().value(0),
+ 7
+ );
+}
+
+/// Under the decoder, `10 / (id - 3)` would divide by zero on a row the
filter drops.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_query_auth_where_runs_on_the_admitted_rows_only() {
+ let (_tmp, _server, context) = restricted_people(None).await;
+
+ assert_eq!(
+ id_names(
+ &query(
+ &context,
+ &format!("SELECT id, name FROM {TABLE} WHERE 10 / (id - 3) >
0")
+ )
+ .await
+ ),
+ vec![
+ (7, "grace".to_string()),
+ (8, "heidi".to_string()),
+ (9, "ivan".to_string()),
+ (10, "judy".to_string()),
+ ]
+ );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_query_auth_explain_shows_the_restriction_but_not_the_files() {
+ let (_tmp, _server, context) = restricted_people(None).await;
+
+ let plan = datafusion::arrow::util::pretty::pretty_format_batches(
+ &query(
+ &context,
+ &format!("EXPLAIN SELECT id, name FROM {TABLE} WHERE name =
'grace'"),
+ )
+ .await,
+ )
+ .unwrap()
+ .to_string();
+ assert!(plan.contains("query-auth=restricted"), "{plan}");
+ assert!(!plan.contains("files="), "{plan}");
+}
+
+/// Pruning can leave no split to carry the grant; the counts and trace still
precede the rules.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn
test_query_auth_explain_hides_the_trace_when_pruning_leaves_no_split() {
+ let (_tmp, _server, context) = restricted_people(None).await;
+
+ let plan = datafusion::arrow::util::pretty::pretty_format_batches(
+ &query(
+ &context,
+ &format!("EXPLAIN SELECT id FROM {TABLE} WHERE id > 100"),
+ )
+ .await,
+ )
+ .unwrap()
+ .to_string();
+ assert!(!plan.contains("files="), "{plan}");
+ assert!(!plan.contains("trace="), "{plan}");
+}
+
+/// Rows `(1, {"x":"bad"})` and `(2, {"x":7})`, served under the rule `id > 1`.
+async fn restricted_variants() -> (tempfile::TempDir, RESTServer, SQLContext) {
+ let schema = |options: &[(&str, &str)]| {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Variant(VariantType::new()));
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+ };
+ let tmp = tempfile::tempdir().unwrap();
+ let mut fs_options = Options::new();
+ fs_options.set(
+ CatalogOptions::WAREHOUSE,
+ format!("file://{}", tmp.path().display()),
+ );
+ let fs_catalog = Arc::new(FileSystemCatalog::new(fs_options).unwrap());
+ fs_catalog
+ .create_database("default", true, HashMap::new())
+ .await
+ .unwrap();
+ let identifier = Identifier::new("default", "vguard");
+ fs_catalog
+ .create_table(&identifier, schema(&[]), false)
+ .await
+ .unwrap();
+ let mut writer = SQLContext::new();
+ writer
+ .register_catalog("paimon", fs_catalog.clone())
+ .await
+ .unwrap();
+ common::exec(
+ &writer,
+ r#"INSERT INTO paimon.default.vguard
+ SELECT 1, parse_json('{"x":"bad"}') UNION ALL SELECT 2,
parse_json('{"x":7}')"#,
+ )
+ .await;
+ let location = fs_catalog
+ .get_table(&identifier)
+ .await
+ .unwrap()
+ .location()
+ .to_string();
+
+ let server = start_mock_server(
+ "test_warehouse".to_string(),
+ tmp.path().to_string_lossy().into_owned(),
+ ConfigResponse::new(HashMap::from([(
+ CatalogOptions::PREFIX.to_string(),
+ "mock-test".to_string(),
+ )])),
+ vec!["default".to_string()],
+ )
+ .await;
+ server.add_table_with_schema(
+ "default",
+ "vguard",
+ schema(&[("query-auth.enabled", "true")]),
+ &location,
+ );
+ server.set_auth_response(
+ "default",
+ "vguard",
+ AuthTableQueryResponse {
+ filter: Some(vec![json!({
+ "kind": "LEAF",
+ "transform": {
+ "name": "FIELD_REF",
+ "fieldRef": {"index": 0, "name": "id", "type": "INT"},
+ },
+ "function": "GREATER_THAN",
+ "literals": [1],
+ })
+ .to_string()]),
+ column_masking: None,
+ },
+ );
+ let mut options = Options::new();
+ options.set(CatalogOptions::URI, server.url().unwrap());
+ options.set(CatalogOptions::WAREHOUSE, "test_warehouse");
+ options.set(CatalogOptions::TOKEN_PROVIDER, "bear");
+ options.set(CatalogOptions::TOKEN, "test-token");
+ let catalog = Arc::new(RESTCatalog::new(options, true).await.unwrap());
+ let mut context = SQLContext::new();
+ context.register_catalog("paimon", catalog).await.unwrap();
+ (tmp, server, context)
+}
+
+/// Pushed into the scan, `variant_get` would cast `{"x":"bad"}`, the row the
rule drops.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_query_auth_variant_get_runs_on_the_admitted_rows_only() {
+ let (_tmp, _server, context) = restricted_variants().await;
+
+ let value = single(
+ &query(
+ &context,
+ "SELECT variant_get(payload, '$.x', 'INT') FROM
paimon.default.vguard",
+ )
+ .await,
+ );
+ assert_eq!(
+ datafusion::arrow::util::display::array_value_to_string(&value,
0).unwrap(),
+ "7"
+ );
+}
diff --git a/crates/paimon/src/table/data_evolution_reader.rs
b/crates/paimon/src/table/data_evolution_reader.rs
index 69c87940..933b41b9 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -1454,6 +1454,23 @@ struct BlobViewLookup {
descriptors: HashMap<BlobViewStruct, Option<BlobDescriptor>>,
}
+/// Resolve view references only after the caller has selected authorized rows.
+pub(crate) fn resolve_blob_view_stream(
+ stream: ArrowRecordBatchStream,
+ fields: HashSet<String>,
+ rest_env: RESTEnv,
+) -> ArrowRecordBatchStream {
+ Box::pin(async_stream::try_stream! {
+ let mut stream = stream;
+ let mut lookup = BlobViewLookup::default();
+ while let Some(batch) = stream.next().await {
+ let batch = batch?;
+ lookup.load_missing(rest_env.clone(), &batch, &fields).await?;
+ yield replace_blob_view_columns(batch, &fields, &lookup)?;
+ }
+ })
+}
+
impl BlobViewLookup {
async fn load_missing(
&mut self,
diff --git a/crates/paimon/src/table/managed_blob_reader.rs
b/crates/paimon/src/table/managed_blob_reader.rs
index c4e776b1..71379474 100644
--- a/crates/paimon/src/table/managed_blob_reader.rs
+++ b/crates/paimon/src/table/managed_blob_reader.rs
@@ -68,10 +68,15 @@ pub(crate) fn resolved_primary_key_blob_fields(
}
let descriptor_fields = options.blob_descriptor_fields();
let inline_fields = options.blob_inline_fields();
+ let video_fields = options.video_frame_fields();
fields
.iter()
.enumerate()
.filter_map(|(index, field)| {
+ // Video frames remain lazy VideoFrameDescriptors, a distinct
format.
+ if video_fields.contains(field.name()) {
+ return None;
+ }
let kind = managed_blob_kind(field.data_type())?;
(!inline_fields.contains(field.name()) ||
descriptor_fields.contains(field.name()))
.then_some((index, kind))
@@ -593,6 +598,46 @@ mod tests {
use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow_schema::{Field, Schema};
+ #[tokio::test]
+ async fn video_frames_remain_lazy_descriptors() {
+ use crate::spec::{BlobType, DataType, VideoFrameDescriptor};
+ use futures::TryStreamExt;
+
+ let frame =
VideoFrameDescriptor::new("memory:/must-not-open-video".into(), 0, 8, 2, -1, 0)
+ .unwrap()
+ .serialize();
+ let fields = [DataField::new(
+ 0,
+ "frame".into(),
+ DataType::Blob(BlobType::new()),
+ )];
+ let batch = RecordBatch::try_from_iter([(
+ "frame",
+ Arc::new(LargeBinaryArray::from(vec![frame.as_slice()])) as
ArrayRef,
+ )])
+ .unwrap();
+ let options = HashMap::from([("video-frame-field".into(),
"frame".into())]);
+ let stream = Box::pin(futures::stream::iter([Ok(batch)]));
+ let batches: Vec<_> = resolve_primary_key_blob_stream(
+ stream,
+ &fields,
+ &CoreOptions::new(&options),
+ FileIOBuilder::new("memory").build().unwrap(),
+ 2,
+ None,
+ )
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(batches.len(), 1);
+ let values = batches[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<LargeBinaryArray>()
+ .unwrap();
+ assert_eq!(values.value(0), frame);
+ }
+
#[tokio::test]
async fn payload_filter_short_circuits_boolean_branches_and_null_checks() {
use crate::spec::{BlobType, DataType, Datum, IntType,
PredicateBuilder};
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 6200bc6b..56c27d35 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -444,10 +444,12 @@ impl Table {
}
/// Whether this user may read this table; `None` when it is not
- /// `query-auth.enabled`. `query_auth` is the loaded option, as in Java.
+ /// `query-auth.enabled`. `query_auth` is the loaded option, as in Java;
+ /// `select` is what the read touches, `None` for the whole table.
pub(crate) async fn authorize_read(
&self,
query_auth: bool,
+ select: Option<Vec<String>>,
) -> Result<Option<std::sync::Arc<query_auth::QueryAuthGrant>>> {
let local = CoreOptions::new(self.schema.options());
let Some(rest_env) = &self.rest_env else {
@@ -478,13 +480,12 @@ impl Table {
query_auth::unsupported("this table handle was assembled rather
than loaded")
})?;
- // Naming a system column here would fail the server's column check.
let response = rest_env
- .table_query_auth(self.schema.id(), self.schema.fields(), None)
+ .table_query_auth(self.schema.id(), self.schema.fields(),
select.clone())
.await?;
- Ok(Some(std::sync::Arc::new(query_auth::QueryAuthGrant::new(
- response, session,
- ))))
+ Ok(Some(std::sync::Arc::new(
+ query_auth::QueryAuthGrant::parse(&response, session, select,
self.schema.fields())?,
+ )))
}
/// Handed out once per catalog-loaded table; wraps only after 2^64 loads.
diff --git a/crates/paimon/src/table/query_auth.rs
b/crates/paimon/src/table/query_auth.rs
index 15d03d6d..7614c602 100644
--- a/crates/paimon/src/table/query_auth.rs
+++ b/crates/paimon/src/table/query_auth.rs
@@ -17,24 +17,51 @@
//! What the REST server authorized a user to read from one table.
+mod rules;
+
use crate::api::AuthTableQueryResponse;
+pub(crate) use rules::{filter_batch, Rules};
-/// The server's answer for one user on one table, kept unparsed; `session`
-/// ties it to the handle that asked, as the response names no table or user.
+/// The server's answer for one user on one table; `session` ties it to the
+/// handle that asked, as the response names no table or user.
#[derive(Debug, PartialEq)]
pub(crate) struct QueryAuthGrant {
- response: AuthTableQueryResponse,
session: u64,
+ /// The columns the request asked about; `None` asked about the whole
table.
+ select: Option<Vec<String>>,
+ rules: Rules,
}
impl QueryAuthGrant {
- pub(crate) fn new(response: AuthTableQueryResponse, session: u64) -> Self {
- Self { response, session }
+ /// Parses the rules against `fields`, the schema the server ruled on.
+ pub(crate) fn parse(
+ response: &AuthTableQueryResponse,
+ session: u64,
+ select: Option<Vec<String>>,
+ fields: &[crate::spec::DataField],
+ ) -> crate::Result<Self> {
+ Ok(Self::new(session, select, Rules::parse(response, fields)?))
}
- /// The only case this client can serve.
+ pub(crate) fn new(session: u64, select: Option<Vec<String>>, rules: Rules)
-> Self {
+ Self {
+ session,
+ select,
+ rules,
+ }
+ }
+
+ /// No row filter and no masking.
pub(crate) fn is_unrestricted(&self) -> bool {
- self.response.is_unrestricted()
+ self.rules.is_empty()
+ }
+
+ pub(crate) fn rules(&self) -> &Rules {
+ &self.rules
+ }
+
+ pub(crate) fn select(&self) -> Option<&[String]> {
+ self.select.as_deref()
}
/// A view of another schema is not the one the server ruled on.
@@ -128,6 +155,28 @@ fn contains(wide: &crate::spec::DataType, narrow:
&crate::spec::DataType) -> boo
}
}
+/// Every leaf's column name, system columns included.
+pub(crate) fn leaf_names(
+ predicates: &[crate::spec::Predicate],
+) -> std::collections::HashSet<String> {
+ fn collect(predicate: &crate::spec::Predicate, out: &mut
std::collections::HashSet<String>) {
+ use crate::spec::Predicate;
+ match predicate {
+ Predicate::Leaf { column, .. } => {
+ out.insert(column.clone());
+ }
+ Predicate::And(children) | Predicate::Or(children) => {
+ children.iter().for_each(|child| collect(child, out));
+ }
+ Predicate::Not(inner) => collect(inner, out),
+ Predicate::AlwaysTrue | Predicate::AlwaysFalse => {}
+ }
+ }
+ let mut out = std::collections::HashSet::new();
+ predicates.iter().for_each(|p| collect(p, &mut out));
+ out
+}
+
/// A refusal naming the option, so callers never match on prose.
pub(crate) fn unsupported(reason: &str) -> crate::Error {
crate::Error::Unsupported {
@@ -179,6 +228,75 @@ pub(crate) fn reject_noncanonical_fields(
Ok(())
}
+/// A strict `variant_get` in the read type casts every stored row, the ones
the
+/// rules drop included; Java Spark keeps it above the scan.
+pub(crate) fn reject_throwing_extractions(
+ read_type: &[crate::spec::DataField],
+) -> crate::Result<()> {
+ use crate::spec::{is_variant_extraction_row, parse_variant_metadata,
DataType};
+ fn throws(data_type: &DataType) -> bool {
+ match data_type {
+ DataType::Row(row) if is_variant_extraction_row(row) => {
+ row.fields().iter().any(|field| {
+ field.description().is_none_or(|description| {
+ parse_variant_metadata(description).map_or(true, |m|
m.fail_on_error())
+ })
+ })
+ }
+ DataType::Row(row) => row.fields().iter().any(|field|
throws(field.data_type())),
+ // Nested evolution reaches through collections too.
+ DataType::Array(array) => throws(array.element_type()),
+ DataType::Multiset(multiset) => throws(multiset.element_type()),
+ DataType::Map(map) => throws(map.key_type()) ||
throws(map.value_type()),
+ _ => false,
+ }
+ }
+ match read_type.iter().find(|field| throws(field.data_type())) {
+ Some(field) => Err(unsupported(&format!(
+ "a Variant extraction on '{}' that can fail would run before the
server's rules",
+ field.name()
+ ))),
+ None => Ok(()),
+ }
+}
+
+/// Readers resolve a leaf by `index` or by `column`, so a scope checked by
name
+/// holds only if both, and the type, name one field of `fields`.
+pub(crate) fn reject_noncanonical_leaves(
+ predicates: &[crate::spec::Predicate],
+ fields: &[crate::spec::DataField],
+) -> crate::Result<()> {
+ use crate::spec::Predicate;
+ fn check(predicate: &Predicate, fields: &[crate::spec::DataField]) ->
crate::Result<()> {
+ match predicate {
+ Predicate::Leaf {
+ column,
+ index,
+ data_type,
+ ..
+ } => {
+ if fields
+ .get(*index)
+ .is_some_and(|f| f.name() == column && f.data_type() ==
data_type)
+ {
+ Ok(())
+ } else {
+ Err(unsupported(&format!(
+ "the filter on '{column}' points at another field by
index or type; \
+ build filters with PredicateBuilder"
+ )))
+ }
+ }
+ Predicate::And(children) | Predicate::Or(children) => {
+ children.iter().try_for_each(|child| check(child, fields))
+ }
+ Predicate::Not(inner) => check(inner, fields),
+ Predicate::AlwaysTrue | Predicate::AlwaysFalse => Ok(()),
+ }
+ }
+ predicates.iter().try_for_each(|p| check(p, fields))
+}
+
#[cfg(test)]
mod tests {
use super::reject_system_columns;
@@ -222,10 +340,8 @@ mod tests {
async fn test_a_grant_is_pinned_to_the_handle_that_obtained_it() {
let a = crate::table::rest_query_auth_table().await;
let b = crate::table::rest_query_auth_table().await;
- let grant = super::QueryAuthGrant::new(
- crate::api::AuthTableQueryResponse::default(),
- a.query_auth_session().unwrap(),
- );
+ let grant =
+ super::QueryAuthGrant::new(a.query_auth_session().unwrap(), None,
Default::default());
assert!(grant.matches_table(&a));
assert!(
!grant.matches_table(&b),
@@ -251,7 +367,7 @@ mod tests {
"1".to_string(),
)]));
assert!(!table.is_time_traveled(), "{selector} sets no flag");
- let err = table.authorize_read(true).await.unwrap_err();
+ let err = table.authorize_read(true, None).await.unwrap_err();
assert!(
matches!(err, crate::Error::Unsupported { ref message }
if message.contains("time-travelled or branch read")),
@@ -285,7 +401,7 @@ mod tests {
("scan.snapshot-id".to_string(), "invalid".to_string()),
]));
assert!(table.reads_another_schema().unwrap());
- assert!(table.authorize_read(false).await.unwrap().is_none());
+ assert!(table.authorize_read(false, None).await.unwrap().is_none());
}
#[tokio::test]
@@ -302,8 +418,9 @@ mod tests {
async fn test_a_grant_does_not_cross_into_a_travelled_or_branch_view() {
let table = crate::table::rest_query_auth_table().await;
let grant = super::QueryAuthGrant::new(
- crate::api::AuthTableQueryResponse::default(),
table.query_auth_session().unwrap(),
+ None,
+ Default::default(),
);
assert!(grant.matches_table(&table));
@@ -547,7 +664,7 @@ mod tests {
async fn test_time_travelled_or_branch_read_is_refused() {
let mut travelled = rest_query_auth_table().await;
travelled.time_traveled = true;
- let err = travelled.authorize_read(true).await.unwrap_err();
+ let err = travelled.authorize_read(true, None).await.unwrap_err();
assert!(
matches!(err, crate::Error::Unsupported { ref message }
if message.contains("time-travelled or branch read")),
@@ -556,6 +673,6 @@ mod tests {
let mut branch = rest_query_auth_table().await;
branch.branch_reference = true;
- assert!(branch.authorize_read(true).await.is_err());
+ assert!(branch.authorize_read(true, None).await.is_err());
}
}
diff --git a/crates/paimon/src/table/query_auth/rules.rs
b/crates/paimon/src/table/query_auth/rules.rs
new file mode 100644
index 00000000..20710390
--- /dev/null
+++ b/crates/paimon/src/table/query_auth/rules.rs
@@ -0,0 +1,699 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! The server's row filter, parsed against the schema it ruled on and applied
+//! to read batches, as Java `TableQueryAuthResult` does.
+
+use super::unsupported;
+use crate::api::AuthTableQueryResponse;
+use crate::arrow::residual::{evaluate_exact_leaf_predicate,
sanitize_filter_mask};
+use crate::spec::{DataField, Predicate};
+use crate::Result;
+use arrow_array::{Array, ArrayRef, BooleanArray, Float32Array, Float64Array,
RecordBatch};
+use std::collections::HashSet;
+use std::sync::Arc;
+
+/// The rules of one grant; leaf indices are table-schema positions.
+#[derive(Debug, Default, PartialEq)]
+pub(crate) struct Rules {
+ pub(crate) filters: Vec<Predicate>,
+}
+
+impl Rules {
+ /// Validated like Java `validateAgainstSchema`: anything unknown fails
closed.
+ pub(crate) fn parse(response: &AuthTableQueryResponse, fields:
&[DataField]) -> Result<Self> {
+ let mut filters = Vec::new();
+ for json in response.filter.iter().flatten() {
+ if json.is_empty() {
+ return Err(unsupported("the server sent an empty row filter"));
+ }
+ let filter =
+ Predicate::from_rest_json(json, fields).map_err(
+ |e| match mentioned_system_column(json) {
+ Some(name) => unsupported(&format!(
+ "the server's row filter reads the system column
'{name}', which a \
+ query-auth read never projects"
+ )),
+ None => unsupported(&format!("cannot parse the
server's row filter: {e}")),
+ },
+ )?;
+ filters.push(filter);
+ }
+
+ // Not applied yet: refusing beats returning raw values.
+ if response
+ .column_masking
+ .as_ref()
+ .is_some_and(|masks| !masks.is_empty())
+ {
+ return Err(unsupported("this client does not apply column masking
yet"));
+ }
+ Ok(Self { filters })
+ }
+
+ pub(crate) fn is_empty(&self) -> bool {
+ self.filters.is_empty()
+ }
+
+ /// Table-schema indices the row filter reads.
+ pub(crate) fn filter_columns(&self) -> HashSet<usize> {
+ let mut out = HashSet::new();
+ for filter in &self.filters {
+ filter.collect_leaf_field_indices(&mut out);
+ }
+ out
+ }
+}
+
+fn mentioned_system_column(json: &str) -> Option<&'static str> {
+ [
+ crate::spec::ROW_ID_FIELD_NAME,
+ crate::spec::SEQUENCE_NUMBER_FIELD_NAME,
+ crate::spec::VALUE_KIND_FIELD_NAME,
+ crate::spec::ROW_KIND_FIELD_NAME,
+ ]
+ .into_iter()
+ .find(|name| json.contains(name))
+}
+
+// ---------------------------------------------------------------------------
+// Row filter
+// ---------------------------------------------------------------------------
+
+/// Keeps the rows passing every filter. `batch` columns pair 1:1 with
+/// `batch_fields`; leaf indices point into `schema_fields`. Anything that
+/// cannot be evaluated is an error, never a kept row.
+pub(crate) fn filter_batch(
+ batch: &RecordBatch,
+ filters: &[Predicate],
+ schema_fields: &[DataField],
+ batch_fields: &[DataField],
+) -> Result<RecordBatch> {
+ let mut keep: Option<BooleanArray> = None;
+ for filter in filters {
+ let mask = rule_mask(batch, filter, schema_fields, batch_fields)?;
+ keep = Some(match keep {
+ Some(existing) => combine(&existing, &mask, false)?,
+ None => mask,
+ });
+ }
+ let Some(keep) = keep else {
+ return Ok(batch.clone());
+ };
+ arrow_select::filter::filter_record_batch(batch, &keep).map_err(|e|
eval_err(&e))
+}
+
+/// Java leaf semantics: a null value fails every leaf but `IS NULL`, for the
+/// negated functions too. The REST parser builds no `NOT`, so two-valued
+/// AND/OR over those leaves is exactly Java's `test`.
+fn rule_mask(
+ batch: &RecordBatch,
+ predicate: &Predicate,
+ schema_fields: &[DataField],
+ batch_fields: &[DataField],
+) -> Result<BooleanArray> {
+ match predicate {
+ Predicate::AlwaysTrue => Ok(BooleanArray::from(vec![true;
batch.num_rows()])),
+ Predicate::AlwaysFalse => Ok(BooleanArray::from(vec![false;
batch.num_rows()])),
+ Predicate::And(children) | Predicate::Or(children) => {
+ let or = matches!(predicate, Predicate::Or(_));
+ let mut combined: Option<BooleanArray> = None;
+ for child in children {
+ let mask = rule_mask(batch, child, schema_fields,
batch_fields)?;
+ combined = Some(match combined {
+ Some(existing) => combine(&existing, &mask, or)?,
+ None => mask,
+ });
+ }
+ combined.ok_or_else(|| unsupported("the server's row filter has an
empty AND/OR"))
+ }
+ // Its null semantics differ from Java's negated leaves.
+ Predicate::Not(_) => Err(unsupported("the server's row filter has a
NOT")),
+ Predicate::Leaf {
+ index,
+ op,
+ literals,
+ ..
+ } => {
+ let field = schema_fields
+ .get(*index)
+ .ok_or_else(|| unsupported("the server's row filter reads an
unknown column"))?;
+ let column = column_of(batch, field, batch_fields)?;
+ let column = canonicalize_nan(&column);
+ let mask = evaluate_exact_leaf_predicate(&column,
field.data_type(), *op, literals)
+ .map_err(|e| eval_err(&e))?;
+ Ok(sanitize_filter_mask(mask))
+ }
+ }
+}
+
+fn combine(left: &BooleanArray, right: &BooleanArray, or: bool) ->
Result<BooleanArray> {
+ let combined = if or {
+ arrow_arith::boolean::or(left, right)
+ } else {
+ arrow_arith::boolean::and(left, right)
+ };
+ combined.map_err(|e| eval_err(&e))
+}
+
+/// The column holding `field`, matched by id and name so an alias cannot
stand in.
+fn column_of(
+ batch: &RecordBatch,
+ field: &DataField,
+ batch_fields: &[DataField],
+) -> Result<ArrayRef> {
+ let position = batch_fields
+ .iter()
+ .position(|f| f.id() == field.id() && f.name() == field.name())
+ .ok_or_else(|| {
+ unsupported(&format!(
+ "the read does not carry '{}', which the server's rules need",
+ field.name()
+ ))
+ })?;
+ let column = batch.column(position);
+ let declared = crate::arrow::paimon_type_to_arrow(field.data_type())?;
+ if column.data_type() == &declared {
+ return Ok(Arc::clone(column));
+ }
+ arrow_cast::cast(column, &declared).map_err(|e| eval_err(&e))
+}
+
+/// Java's `Float.compare` puts every NaN above every number; Arrow's total
+/// order puts a negative NaN below them, so `f < 0` would admit it.
+fn canonicalize_nan(column: &ArrayRef) -> ArrayRef {
+ match column.data_type() {
+ arrow_schema::DataType::Float32 => match
column.as_any().downcast_ref::<Float32Array>() {
+ Some(values) if values.iter().any(|v| v.is_some_and(f32::is_nan))
=> {
+ Arc::new(values.unary::<_,
arrow_array::types::Float32Type>(|v| {
+ if v.is_nan() {
+ f32::NAN
+ } else {
+ v
+ }
+ }))
+ }
+ _ => Arc::clone(column),
+ },
+ arrow_schema::DataType::Float64 => match
column.as_any().downcast_ref::<Float64Array>() {
+ Some(values) if values.iter().any(|v| v.is_some_and(f64::is_nan))
=> {
+ Arc::new(values.unary::<_,
arrow_array::types::Float64Type>(|v| {
+ if v.is_nan() {
+ f64::NAN
+ } else {
+ v
+ }
+ }))
+ }
+ _ => Arc::clone(column),
+ },
+ _ => Arc::clone(column),
+ }
+}
+
+fn eval_err(e: &dyn std::fmt::Display) -> crate::Error {
+ unsupported(&format!("cannot evaluate the server's rules: {e}"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::{
+ BigIntType, BooleanType, DataType, DateType, Datum, DecimalType,
DoubleType, FloatType,
+ IntType, PredicateBuilder, PredicateOperator, TimestampType,
VarBinaryType, VarCharType,
+ };
+ use arrow_array::{
+ Date32Array, Decimal128Array, Int32Array, Int64Array,
RecordBatchOptions, StringArray,
+ TimestampMillisecondArray,
+ };
+ use arrow_schema::{Field, Schema};
+
+ const NULL: &str = r#"{"name":"NULL"}"#;
+
+ fn field(id: i32, name: &str, data_type: DataType) -> DataField {
+ DataField::new(id, name.to_string(), data_type)
+ }
+
+ fn string() -> DataType {
+ DataType::VarChar(VarCharType::string_type())
+ }
+
+ fn batch(fields: &[DataField], columns: Vec<ArrayRef>) -> RecordBatch {
+ let schema: Vec<Field> = fields
+ .iter()
+ .map(|f| {
+ let data_type =
crate::arrow::paimon_type_to_arrow(f.data_type()).unwrap();
+ Field::new(f.name(), data_type, f.data_type().is_nullable())
+ })
+ .collect();
+ RecordBatch::try_new(Arc::new(Schema::new(schema)), columns).unwrap()
+ }
+
+ fn parse(filters: &[&str], masks: &[(&str, &str)], fields: &[DataField])
-> Result<Rules> {
+ let response = AuthTableQueryResponse {
+ filter: Some(filters.iter().map(|f| f.to_string()).collect()),
+ column_masking: Some(
+ masks
+ .iter()
+ .map(|(column, json)| (column.to_string(),
json.to_string()))
+ .collect(),
+ ),
+ };
+ Rules::parse(&response, fields)
+ }
+
+ fn refusal<T: std::fmt::Debug>(result: Result<T>) -> String {
+ match result {
+ Err(crate::Error::Unsupported { message }) => message,
+ other => panic!("expected a refusal, got {other:?}"),
+ }
+ }
+
+ /// Java `LeafPredicate` JSON; the field resolves by name and the local
type wins.
+ fn leaf(function: &str, field: &str, literals: &str) -> String {
+ format!(
+
r#"{{"kind":"LEAF","transform":{{"name":"FIELD_REF","fieldRef":{{"index":0,"name":"{field}","type":"INT"}}}},"function":"{function}","literals":{literals}}}"#
+ )
+ }
+
+ fn compound(function: &str, children: &[String]) -> String {
+ format!(
+ r#"{{"kind":"COMPOUND","function":"{function}","children":[{}]}}"#,
+ children.join(",")
+ )
+ }
+
+ fn constant(function: &str) -> String {
+ format!(
+
r#"{{"kind":"LEAF","transform":{{"name":"NULL"}},"function":"{function}","literals":[]}}"#
+ )
+ }
+
+ fn ids(batch: &RecordBatch) -> Vec<i32> {
+ let ids = batch.column(0).as_any().downcast_ref::<Int32Array>();
+ ids.unwrap().values().to_vec()
+ }
+
+ // ---------------------------------------------------------------------
+ // Parsing
+ // ---------------------------------------------------------------------
+
+ fn rule_fields() -> Vec<DataField> {
+ let bytes = VarBinaryType::new(VarBinaryType::MAX_LENGTH).unwrap();
+ let not_null = VarCharType::with_nullable(false,
VarCharType::MAX_LENGTH).unwrap();
+ vec![
+ field(0, "id", DataType::Int(IntType::new())),
+ field(1, "name", string()),
+ field(2, "alias", string()),
+ field(3, "bin", DataType::VarBinary(bytes)),
+ field(4, "nn", DataType::VarChar(not_null)),
+ ]
+ }
+
+ #[test]
+ fn test_parse_reads_the_rules() {
+ let fields = rule_fields();
+ let rules = parse(
+ &[
+ &leaf("GREATER_THAN", "id", "[1]"),
+ &leaf("IS_NOT_NULL", "alias", "[]"),
+ ],
+ &[],
+ &fields,
+ )
+ .unwrap();
+ assert_eq!(rules.filters.len(), 2);
+ assert_eq!(rules.filter_columns(), HashSet::from([0, 2]));
+
+ let none = AuthTableQueryResponse::default();
+ assert!(Rules::parse(&none, &fields).unwrap().is_empty());
+ assert!(parse(&[], &[], &fields).unwrap().is_empty());
+ }
+
+ #[test]
+ fn test_any_mask_is_refused() {
+ let message = refusal(parse(&[], &[("name", NULL)], &rule_fields()));
+ assert!(message.contains("column masking"), "{message}");
+ }
+
+ #[test]
+ fn test_parse_fails_closed() {
+ let fields = rule_fields();
+ for (filter, reason) in [
+ (String::new(), "empty row filter"),
+ (" ".to_string(), "cannot parse"),
+ ("null".to_string(), "cannot parse"),
+ ("{".to_string(), "cannot parse"),
+ (leaf("EQUAL", "missing", "[1]"), "unknown field `missing`"),
+ (leaf("EQUAL", "id", r#"["one"]"#), "cannot parse"),
+ (compound("AND", &[]), "empty children"),
+ ] {
+ let message = refusal(parse(&[&filter], &[], &fields));
+ assert!(message.contains(reason), "{filter:?}: {message}");
+ }
+ // One bad entry spoils the list.
+ let good = leaf("EQUAL", "id", "[1]");
+ assert!(refusal(parse(&[&good, ""], &[], &fields)).contains("empty row
filter"));
+ }
+
+ #[test]
+ fn test_a_filter_on_a_system_column_is_refused() {
+ let fields = rule_fields();
+ for column in ["_ROW_ID", "_SEQUENCE_NUMBER", "_VALUE_KIND",
"rowkind"] {
+ let message = refusal(parse(&[&leaf("EQUAL", column, "[1]")], &[],
&fields));
+ assert!(
+ message.contains(&format!("system column '{column}'")),
+ "{message}"
+ );
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Row filter
+ // ---------------------------------------------------------------------
+
+ /// `k` names the row; every other column is null in the last one.
+ fn typed_fields() -> Vec<DataField> {
+ vec![
+ field(0, "k", DataType::Int(IntType::with_nullable(false))),
+ field(1, "i", DataType::Int(IntType::new())),
+ field(2, "b", DataType::BigInt(BigIntType::new())),
+ field(3, "d", DataType::Double(DoubleType::new())),
+ field(4, "f", DataType::Float(FloatType::new())),
+ field(5, "s", string()),
+ field(6, "m", DataType::Decimal(DecimalType::new(5, 2).unwrap())),
+ field(7, "dt", DataType::Date(DateType::new())),
+ field(8, "ts",
DataType::Timestamp(TimestampType::new(3).unwrap())),
+ field(9, "flag", DataType::Boolean(BooleanType::new())),
+ ]
+ }
+
+ /// Four values, then a null.
+ fn with_null<T>(values: [T; 4]) -> Vec<Option<T>> {
+ values.into_iter().map(Some).chain([None]).collect()
+ }
+
+ fn typed_batch() -> RecordBatch {
+ let decimals = Decimal128Array::from(with_null([110, 200, 250, 1000]));
+ batch(
+ &typed_fields(),
+ vec![
+ Arc::new(Int32Array::from(vec![0, 1, 2, 3, 4])),
+ Arc::new(Int32Array::from(with_null([1, 2, 3, 4]))),
+ Arc::new(Int64Array::from(with_null([10, 20, 30, 40]))),
+ Arc::new(Float64Array::from(with_null([-1.5, 0.0, -0.0,
f64::NAN]))),
+ Arc::new(Float32Array::from(with_null([-1.5, 0.0, -0.0,
f32::NAN]))),
+ Arc::new(StringArray::from(with_null([
+ "apple", "apricot", "banana", "a_c",
+ ]))),
+ Arc::new(decimals.with_precision_and_scale(5, 2).unwrap()),
+ Arc::new(Date32Array::from(with_null([19000, 19001, 19002,
19003]))),
+ Arc::new(TimestampMillisecondArray::from(with_null([
+ 1000, 2000, 3000, 4000,
+ ]))),
+ Arc::new(BooleanArray::from(with_null([true, false, true,
false]))),
+ ],
+ )
+ }
+
+ /// The rows of the typed batch that `filters` keep.
+ fn kept(filters: &[Predicate]) -> Vec<i32> {
+ let fields = typed_fields();
+ ids(&filter_batch(&typed_batch(), filters, &fields, &fields).unwrap())
+ }
+
+ #[test]
+ fn test_filter_matrix_follows_java_leaf_semantics() {
+ let fields = typed_fields();
+ // A null value fails every leaf but IS_NULL, the negated ones too.
+ let cases = [
+ (leaf("EQUAL", "i", "[2]"), vec![1]),
+ (leaf("NOT_EQUAL", "i", "[2]"), vec![0, 2, 3]),
+ (leaf("LESS_THAN", "i", "[3]"), vec![0, 1]),
+ (leaf("GREATER_OR_EQUAL", "i", "[3]"), vec![2, 3]),
+ (leaf("IN", "i", "[1,3,null]"), vec![0, 2]),
+ (leaf("NOT_IN", "i", "[1,3]"), vec![1, 3]),
+ (leaf("NOT_IN", "i", "[1,null]"), vec![]),
+ (leaf("BETWEEN", "i", "[2,3]"), vec![1, 2]),
+ (leaf("NOT_BETWEEN", "i", "[2,3]"), vec![0, 3]),
+ (leaf("IS_NULL", "i", "[]"), vec![4]),
+ (leaf("IS_NOT_NULL", "i", "[]"), vec![0, 1, 2, 3]),
+ (leaf("EQUAL", "i", "[null]"), vec![]),
+ (leaf("GREATER_OR_EQUAL", "b", "[30]"), vec![2, 3]),
+ (leaf("NOT_IN", "b", "[10,40]"), vec![1, 2]),
+ (leaf("BETWEEN", "b", "[15,35]"), vec![1, 2]),
+ // `Double.compare`: -0.0 sorts below 0.0, NaN above every number.
+ (leaf("EQUAL", "d", "[0.0]"), vec![1]),
+ (leaf("EQUAL", "d", "[-0.0]"), vec![2]),
+ (leaf("NOT_EQUAL", "d", "[0.0]"), vec![0, 2, 3]),
+ (leaf("LESS_THAN", "d", "[0.0]"), vec![0, 2]),
+ (leaf("GREATER_OR_EQUAL", "d", "[0.0]"), vec![1, 3]),
+ (leaf("IN", "d", "[0.0,-1.5]"), vec![0, 1]),
+ (leaf("NOT_IN", "d", "[0.0]"), vec![0, 2, 3]),
+ (leaf("BETWEEN", "d", "[-1.0,1.0]"), vec![1, 2]),
+ (leaf("NOT_BETWEEN", "d", "[-1.0,1.0]"), vec![0, 3]),
+ (leaf("EQUAL", "f", "[-1.5]"), vec![0]),
+ (leaf("NOT_EQUAL", "f", "[-0.0]"), vec![0, 1, 3]),
+ (leaf("LESS_THAN", "f", "[0.0]"), vec![0, 2]),
+ (leaf("GREATER_OR_EQUAL", "f", "[0.0]"), vec![1, 3]),
+ (leaf("EQUAL", "s", r#"["banana"]"#), vec![2]),
+ (leaf("NOT_EQUAL", "s", r#"["banana"]"#), vec![0, 1, 3]),
+ (leaf("LESS_THAN", "s", r#"["apricot"]"#), vec![0, 3]),
+ (leaf("GREATER_OR_EQUAL", "s", r#"["apricot"]"#), vec![1, 2]),
+ (leaf("IN", "s", r#"["apple","banana"]"#), vec![0, 2]),
+ (leaf("NOT_IN", "s", r#"["apple"]"#), vec![1, 2, 3]),
+ (leaf("BETWEEN", "s", r#"["apple","b"]"#), vec![0, 1]),
+ (leaf("NOT_BETWEEN", "s", r#"["apple","b"]"#), vec![2, 3]),
+ (leaf("STARTS_WITH", "s", r#"["ap"]"#), vec![0, 1]),
+ (leaf("LIKE", "s", r#"["%an%"]"#), vec![2]),
+ (leaf("LIKE", "s", r#"["a_p%"]"#), vec![0]),
+ (leaf("LIKE", "s", r#"["a\\_c"]"#), vec![3]),
+ (leaf("IS_NULL", "s", "[]"), vec![4]),
+ (leaf("EQUAL", "flag", "[true]"), vec![0, 2]),
+ (leaf("NOT_EQUAL", "flag", "[true]"), vec![1, 3]),
+ (leaf("IN", "flag", "[false]"), vec![1, 3]),
+ (leaf("IS_NOT_NULL", "flag", "[]"), vec![0, 1, 2, 3]),
+ ];
+ for (json, expected) in cases {
+ let rules = parse(&[&json], &[], &fields).unwrap();
+ assert_eq!(kept(&rules.filters), expected, "{json}");
+ }
+ }
+
+ #[test]
+ fn test_filter_compares_decimals_dates_and_timestamps_by_value() {
+ // The REST parser takes no such literal, so these leaves are built
directly.
+ let fields = typed_fields();
+ let b = PredicateBuilder::new(&fields);
+ let dec = |unscaled, scale| Datum::Decimal {
+ unscaled,
+ precision: 10,
+ scale,
+ };
+ let ts = |millis, nanos| Datum::Timestamp { millis, nanos };
+ let cases = [
+ (b.equal("m", dec(2, 0)), vec![1]),
+ (b.equal("m", dec(20, 1)), vec![1]),
+ (b.not_equal("m", dec(25, 1)), vec![0, 1, 3]),
+ (b.greater_than("m", dec(2001, 3)), vec![2, 3]),
+ (b.less_or_equal("m", dec(11, 1)), vec![0]),
+ (b.between("m", dec(11, 1), dec(25, 1)), vec![0, 1, 2]),
+ (b.not_between("m", dec(11, 1), dec(25, 1)), vec![3]),
+ (b.is_in("m", vec![dec(10, 0), dec(1100, 3)]), vec![0, 3]),
+ (b.is_not_in("m", vec![dec(2, 0)]), vec![0, 2, 3]),
+ (b.greater_than("dt", Datum::Date(19001)), vec![2, 3]),
+ (
+ b.between("dt", Datum::Date(19000), Datum::Date(19001)),
+ vec![0, 1],
+ ),
+ (b.not_equal("dt", Datum::Date(19000)), vec![1, 2, 3]),
+ (
+ b.is_in("dt", vec![Datum::Date(19000), Datum::Date(19003)]),
+ vec![0, 3],
+ ),
+ (b.is_null("dt"), vec![4]),
+ (b.less_than("ts", ts(2000, 0)), vec![0]),
+ // One nanosecond past a millisecond is not rounded away.
+ (b.greater_or_equal("ts", ts(2000, 1)), vec![2, 3]),
+ (b.between("ts", ts(1500, 0), ts(3000, 0)), vec![1, 2]),
+ (b.not_between("ts", ts(1500, 0), ts(3000, 0)), vec![0, 3]),
+ (b.is_not_in("ts", vec![ts(1000, 0)]), vec![1, 2, 3]),
+ ];
+ for (predicate, expected) in cases {
+ let predicate = predicate.unwrap();
+ assert_eq!(
+ kept(std::slice::from_ref(&predicate)),
+ expected,
+ "{predicate}"
+ );
+ }
+ }
+
+ #[test]
+ fn test_every_nan_sorts_above_every_number() {
+ let fields = vec![
+ field(0, "k", DataType::Int(IntType::with_nullable(false))),
+ field(1, "d", DataType::Double(DoubleType::new())),
+ field(2, "f", DataType::Float(FloatType::new())),
+ ];
+ // Arrow's total order alone would put the sign-bit NaN below every
number.
+ let rows = batch(
+ &fields,
+ vec![
+ Arc::new(Int32Array::from(vec![0, 1, 2, 3])),
+ Arc::new(Float64Array::from(vec![-f64::NAN, f64::NAN, -1.0,
1.0])),
+ Arc::new(Float32Array::from(vec![-f32::NAN, f32::NAN, -1.0,
1.0])),
+ ],
+ );
+ for column in ["d", "f"] {
+ for (function, literals, expected) in [
+ ("LESS_THAN", "[0.0]", vec![2]),
+ ("GREATER_THAN", "[0.0]", vec![0, 1, 3]),
+ ("GREATER_THAN", "[1.0]", vec![0, 1]),
+ ("NOT_BETWEEN", "[-2.0,2.0]", vec![0, 1]),
+ ] {
+ let json = leaf(function, column, literals);
+ let rules = parse(&[&json], &[], &fields).unwrap();
+ let out = filter_batch(&rows, &rules.filters, &fields,
&fields).unwrap();
+ assert_eq!(ids(&out), expected, "{json}");
+ }
+ }
+ }
+
+ #[test]
+ fn test_filters_combine_with_and_or() {
+ let fields = typed_fields();
+ let i_is = |value: &str| leaf("EQUAL", "i", &format!("[{value}]"));
+ let cases = [
+ (
+ vec![compound(
+ "OR",
+ &[i_is("1"), leaf("EQUAL", "s", r#"["banana"]"#)],
+ )],
+ vec![0, 2],
+ ),
+ (
+ vec![compound(
+ "AND",
+ &[
+ leaf("GREATER_OR_EQUAL", "b", "[20]"),
+ leaf("EQUAL", "flag", "[true]"),
+ ],
+ )],
+ vec![2],
+ ),
+ // A null fails NOT_EQUAL but passes IS_NULL.
+ (
+ vec![compound(
+ "OR",
+ &[leaf("NOT_EQUAL", "i", "[2]"), leaf("IS_NULL", "s",
"[]")],
+ )],
+ vec![0, 2, 3, 4],
+ ),
+ (
+ vec![compound(
+ "AND",
+ &[
+ compound("OR", &[i_is("1"), i_is("4")]),
+ leaf("NOT_EQUAL", "s", r#"["apple"]"#),
+ ],
+ )],
+ vec![3],
+ ),
+ // The server's entries are ANDed.
+ (
+ vec![
+ leaf("GREATER_THAN", "i", "[1]"),
+ leaf("EQUAL", "flag", "[true]"),
+ ],
+ vec![2],
+ ),
+ (vec![constant("TRUE")], vec![0, 1, 2, 3, 4]),
+ (vec![constant("FALSE"), leaf("IS_NULL", "i", "[]")], vec![]),
+ ];
+ for (filters, expected) in cases {
+ let filters: Vec<&str> =
filters.iter().map(String::as_str).collect();
+ let rules = parse(&filters, &[], &fields).unwrap();
+ assert_eq!(kept(&rules.filters), expected, "{filters:?}");
+ }
+ assert_eq!(kept(&[]), vec![0, 1, 2, 3, 4]);
+ }
+
+ #[test]
+ fn test_filter_refuses_what_it_cannot_evaluate() {
+ let fields = typed_fields();
+ let rows = typed_batch();
+ let json = leaf("EQUAL", "i", "[1]");
+ let filters = parse(&[&json], &[], &fields).unwrap().filters;
+
+ // Carried under its id and its name, or not at all.
+ for carried in [
+ field(1, "renamed", DataType::Int(IntType::new())),
+ field(99, "i", DataType::Int(IntType::new())),
+ ] {
+ let mut batch_fields = fields.clone();
+ batch_fields[1] = carried;
+ let message = refusal(filter_batch(&rows, &filters, &fields,
&batch_fields));
+ assert!(message.contains("does not carry 'i'"), "{message}");
+ }
+ let narrow = rows.project(&[0, 2]).unwrap();
+ let narrow_fields = [fields[0].clone(), fields[2].clone()];
+ let message = refusal(filter_batch(&narrow, &filters, &fields,
&narrow_fields));
+ assert!(message.contains("does not carry 'i'"), "{message}");
+ // Any position will do.
+ let reordered = rows.project(&[1, 0]).unwrap();
+ let reordered_fields = [fields[1].clone(), fields[0].clone()];
+ let out = filter_batch(&reordered, &filters, &fields,
&reordered_fields).unwrap();
+ assert_eq!(out.num_rows(), 1);
+
+ let mistyped = Predicate::Leaf {
+ column: "i".to_string(),
+ index: 1,
+ data_type: DataType::Int(IntType::new()),
+ op: PredicateOperator::Eq,
+ literals: vec![Datum::String("1".to_string())],
+ };
+ for (filter, reason) in [
+ (Predicate::Not(Box::new(filters[0].clone())), "has a NOT"),
+ (Predicate::And(Vec::new()), "empty AND/OR"),
+ (mistyped, "cannot evaluate"),
+ ] {
+ let message = refusal(filter_batch(&rows, &[filter], &fields,
&fields));
+ assert!(message.contains(reason), "{message}");
+ }
+ let message = refusal(filter_batch(&rows, &filters, &fields[..1],
&fields));
+ assert!(message.contains("unknown column"), "{message}");
+ }
+
+ #[test]
+ fn test_a_zero_column_batch_keeps_its_row_count() {
+ let fields = rule_fields();
+ let options = RecordBatchOptions::new().with_row_count(Some(3));
+ let empty =
+ RecordBatch::try_new_with_options(Arc::new(Schema::empty()),
Vec::new(), &options)
+ .unwrap();
+ for (filter, rows) in [(constant("TRUE"), 3), (constant("FALSE"), 0)] {
+ let rules = parse(&[&filter], &[], &fields).unwrap();
+ let out = filter_batch(&empty, &rules.filters, &fields,
&[]).unwrap();
+ assert_eq!(out.num_rows(), rows, "{filter}");
+ }
+ assert_eq!(
+ filter_batch(&empty, &[], &fields, &[]).unwrap().num_rows(),
+ 3
+ );
+ }
+}
diff --git a/crates/paimon/src/table/source.rs
b/crates/paimon/src/table/source.rs
index 64be1888..90e12e37 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -540,6 +540,15 @@ impl DataSplit {
self.query_auth_grant.as_ref()
}
+ /// Whether the server attached rules to this split's read: its row counts
+ /// and column statistics then describe rows the read will not return as
+ /// they are.
+ pub fn has_query_auth_rules(&self) -> bool {
+ self.query_auth_grant
+ .as_ref()
+ .is_some_and(|grant| !grant.is_unrestricted())
+ }
+
pub fn snapshot_id(&self) -> i64 {
self.snapshot_id
}
@@ -658,8 +667,18 @@ impl DataSplit {
/// [`DataFileMeta::ROW_COUNT_UNKNOWN`] — no arithmetic over a placeholder
/// produces a number a caller may trust.
///
+ /// Also `None` under a query-auth row filter, which drops rows the count
+ /// includes, as Java `QueryAuthSplit` does.
+ ///
/// Reference:
[DataSplit.mergedRowCount()](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java#L133)
pub fn merged_row_count(&self) -> Option<i64> {
+ if self
+ .query_auth_grant
+ .as_ref()
+ .is_some_and(|grant| !grant.rules().filters.is_empty())
+ {
+ return None;
+ }
if let Some(ranges) = &self.row_ranges {
return Some(ranges.iter().map(RowRange::count).sum());
}
@@ -1606,6 +1625,24 @@ mod tests {
assert_eq!(s.merged_row_count(), Some(15));
}
+ #[tokio::test]
+ async fn test_merged_row_count_is_unknown_under_a_row_filter() {
+ let table = crate::table::rest_query_auth_table().await;
+ let grant = |filters| {
+ Some(Arc::new(crate::table::query_auth::QueryAuthGrant::new(
+ table.query_auth_session().unwrap(),
+ None,
+ crate::table::query_auth::Rules { filters },
+ )))
+ };
+ let s = || split(vec![file("a", 10, None)], true);
+ assert_eq!(s().planned(grant(Vec::new())).merged_row_count(),
Some(10));
+ let filtered =
s().planned(grant(vec![crate::spec::Predicate::AlwaysTrue]));
+ assert_eq!(filtered.merged_row_count(), None);
+ // The physical count stays, as Java's `rowCount` does.
+ assert_eq!(filtered.row_count(), 10);
+ }
+
/// A placeholder row count must surface as "unknown", not as a number.
/// Reporting `Some(0)` here lets an engine answer `COUNT(*)` with 0 for a
/// split that holds data.
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index 0d45ea63..71c3d708 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -188,7 +188,7 @@ impl<'a> TableRead<'a> {
///
/// The hook is used only by schema-identical raw reads. Callers must still
/// enforce the expression after the scan because an individual file may
not
- /// be able to build a decoder filter.
+ /// be able to build a decoder filter. A `query-auth.enabled` read refuses
it.
pub fn with_row_filter_factory(self, factory: Arc<dyn
crate::arrow::RowFilterFactory>) -> Self {
match self.0 {
TableReadKind::Paimon(read) => {
@@ -311,22 +311,6 @@ impl<'a> TableRead<'a> {
}
}
-/// Every leaf's column name, system columns included.
-fn collect_leaf_column_names(predicate: &Predicate, out: &mut
std::collections::HashSet<String>) {
- match predicate {
- Predicate::Leaf { column, .. } => {
- out.insert(column.clone());
- }
- Predicate::And(children) | Predicate::Or(children) => {
- children
- .iter()
- .for_each(|child| collect_leaf_column_names(child, out));
- }
- Predicate::Not(inner) => collect_leaf_column_names(inner, out),
- Predicate::AlwaysTrue | Predicate::AlwaysFalse => {}
- }
-}
-
#[derive(Debug, Clone)]
struct PaimonTableRead<'a> {
table: &'a Table,
@@ -507,7 +491,14 @@ impl<'a> PaimonTableRead<'a> {
data_splits: &[DataSplit],
) -> crate::Result<ArrowRecordBatchStream> {
// Streaming primary-key splits are read raw below, so decide here for
all.
- self.ensure_authorized_by_splits(&self.table.schema.core_options(),
data_splits)?;
+ if self
+ .ensure_authorized_by_splits(&self.table.schema.core_options(),
data_splits)?
+ .is_some()
+ {
+ return Err(super::query_auth::unsupported(
+ "a row-kind read cannot apply a row filter or column masking",
+ ));
+ }
let schema = audit_schema_for_read_type(&self.read_type, false)?;
let (streaming, materialized): (Vec<_>, Vec<_>) = data_splits
.iter()
@@ -974,13 +965,13 @@ impl<'a> PaimonTableRead<'a> {
reader.read(splits)
}
- /// Reads only splits carrying a grant that the server imposed nothing;
- /// nothing is fetched here, so a split without one fails closed.
+ /// Every split must carry this handle's grant; returns it when it
restricts
+ /// the read. Nothing is fetched here, so a split without one fails closed.
fn ensure_authorized_by_splits(
&self,
core_options: &CoreOptions,
data_splits: &[DataSplit],
- ) -> crate::Result<()> {
+ ) -> crate::Result<Option<Arc<super::query_auth::QueryAuthGrant>>> {
// Unconditional: unrelated to query-auth.
core_options.ensure_type_paimon_served(&self.table.identifier().full_name())?;
// Decided at plan time, as in Java: a split predating the option, or
built
@@ -988,7 +979,14 @@ impl<'a> PaimonTableRead<'a> {
let required = core_options.query_auth_enabled()
|| data_splits.iter().any(|s| s.query_auth_required());
if !required {
- return Ok(());
+ return Ok(None);
+ }
+ // Its filters pick their own columns and run before any rule; an
+ // expression failing on a dropped row would put the value in its
error.
+ if self.row_filter_factory.is_some() {
+ return Err(super::query_auth::unsupported(
+ "an engine decoder filter would run outside the server's
grant",
+ ));
}
// Only the catalog mints a session, so a handle without one holds no
grant.
if self.table.query_auth_session().is_none() {
@@ -997,10 +995,7 @@ impl<'a> PaimonTableRead<'a> {
));
}
// The read's own scope: a caller can plan clean, then read
differently.
- let mut filter_columns = std::collections::HashSet::new();
- for predicate in &self.data_predicates {
- collect_leaf_column_names(predicate, &mut filter_columns);
- }
+ let filter_columns =
super::query_auth::leaf_names(&self.data_predicates);
super::query_auth::reject_system_columns(
self.read_type
.iter()
@@ -1013,7 +1008,13 @@ impl<'a> PaimonTableRead<'a> {
&self.read_type,
self.table.schema().fields(),
)?;
- // Per split, as Java binds one `QueryAuthSplit` each.
+ super::query_auth::reject_noncanonical_leaves(
+ &self.data_predicates,
+ self.table.schema().fields(),
+ )?;
+ // Per split, as Java binds one `QueryAuthSplit` each; one read
applies one
+ // rule set, so a restricted split's grant must cover them all.
+ let mut restricted: Option<&Arc<super::query_auth::QueryAuthGrant>> =
None;
for split in data_splits {
let Some(grant) = split.query_auth_grant() else {
return Err(super::query_auth::unsupported(
@@ -1027,21 +1028,282 @@ impl<'a> PaimonTableRead<'a> {
scan",
));
}
- if !grant.is_unrestricted() {
+ // The server ruled on these columns only, whether or not it set
rules.
+ if let Some(select) = grant.select() {
+ if let Some(outside) = self
+ .read_type
+ .iter()
+ .map(|f| f.name())
+ .chain(filter_columns.iter().map(String::as_str))
+ .find(|name| !select.iter().any(|s| s == name))
+ {
+ return Err(super::query_auth::unsupported(&format!(
+ "'{outside}' is outside the columns this plan was
authorized for; plan \
+ with it"
+ )));
+ }
+ }
+ if !grant.is_unrestricted() && restricted.is_none() {
+ restricted = Some(grant);
+ }
+ }
+ if let Some(grant) = restricted {
+ if data_splits
+ .iter()
+ .any(|s| s.query_auth_grant().is_none_or(|g| **g != **grant))
+ {
return Err(super::query_auth::unsupported(
- "this client cannot apply a row filter or column masking,
so it refuses \
- rather than return unfiltered rows",
+ "the splits were planned under different rules; re-plan
the scan",
));
}
}
- Ok(())
+ Ok(restricted.cloned())
}
/// Returns an [`ArrowRecordBatchStream`].
pub fn to_arrow(&self, data_splits: &[DataSplit]) ->
crate::Result<ArrowRecordBatchStream> {
- let has_primary_keys = !self.table.schema.primary_keys().is_empty();
let core_options = self.table.schema.core_options();
- self.ensure_authorized_by_splits(&core_options, data_splits)?;
+ match self.ensure_authorized_by_splits(&core_options, data_splits)? {
+ Some(grant) => self.read_restricted(data_splits, &grant),
+ None => self.read_splits(data_splits, &core_options),
+ }
+ }
+
+ /// Reads what the row filter needs, filters on stored values and projects
+ /// back (Java `doAuth`).
+ fn read_restricted(
+ &self,
+ data_splits: &[DataSplit],
+ grant: &super::query_auth::QueryAuthGrant,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ use super::query_auth::{filter_batch, unsupported};
+
+ let schema_fields = self.table.schema().fields().to_vec();
+ let rules = grant.rules();
+ super::query_auth::reject_throwing_extractions(&self.read_type)?;
+ let index_of = |field: &DataField| schema_fields.iter().position(|s|
s.id() == field.id());
+ let needed = rules.filter_columns();
+ let core_options = self.table.schema.core_options();
+ let view_fields = core_options.blob_view_fields();
+ if core_options.blob_view_resolve_enabled() &&
self.table.rest_env().is_some() {
+ if let Some(field) = needed
+ .iter()
+ .map(|i| &schema_fields[*i])
+ .find(|f| view_fields.contains(f.name()))
+ {
+ return Err(unsupported(&format!(
+ "the server's row filter reads resolving BLOB view '{}';
its stored \
+ nullness can differ from the resolved value",
+ field.name(),
+ )));
+ }
+ }
+ // A partly projected column would feed the filter a partial value
(Java
+ // `validateReadType`).
+ for field in &self.read_type {
+ if let Some(index) = index_of(field) {
+ if needed.contains(&index) && field.data_type() !=
schema_fields[index].data_type()
+ {
+ return Err(unsupported(&format!(
+ "the server's row filter reads '{}', which the read
projects only in part",
+ field.name()
+ )));
+ }
+ }
+ }
+
+ let mut physical = self.read_type.clone();
+ let mut needed: Vec<usize> = needed.into_iter().collect();
+ needed.sort_unstable();
+ for index in needed {
+ let field = &schema_fields[index];
+ if !physical.iter().any(|f| f.id() == field.id()) {
+ physical.push(field.clone());
+ }
+ }
+ let predicates = crate::arrow::format::FilePredicates {
+ predicates: self.data_predicates.clone(),
+ row_filter_factory: None,
+ file_fields: schema_fields.clone(),
+ };
+ physical = crate::arrow::residual::widen_scan_fields(&physical,
Some(&predicates));
+ let mut inner = self.clone();
+ inner.read_type = physical.clone();
+ inner.limit = None;
+ let has_blobs = physical.iter().any(|f|
f.data_type().is_blob_file_field());
+ let unresolved_table;
+ if has_blobs {
+ // Keep payload predicates above authorization too: they can open
+ // a descriptor belonging to a row the server excludes.
+ inner.data_predicates.clear();
+ unresolved_table = self
+ .table
+ .copy_with_options(std::collections::HashMap::from([
+ ("blob-as-descriptor".to_string(), "true".to_string()),
+ ("blob-view.resolve.enabled".to_string(),
"false".to_string()),
+ ]));
+ inner.table = &unresolved_table;
+ }
+ let stream = inner.read_splits(data_splits,
&inner.table.schema.core_options())?;
+
+ let filters = rules.filters.clone();
+ let batch_fields = physical.clone();
+ let stream = stream.map(move |batch| {
+ let batch = batch?;
+ let names_match = batch.num_columns() == physical.len()
+ && batch
+ .schema()
+ .fields()
+ .iter()
+ .zip(&physical)
+ .all(|(column, field)| column.name() == field.name());
+ if !names_match {
+ return Err(unsupported(
+ "the read returned columns the rules cannot address",
+ ));
+ }
+ filter_batch(&batch, &filters, &schema_fields, &physical)
+ });
+ let stream: ArrowRecordBatchStream = Box::pin(stream);
+ if has_blobs {
+ return self.finish_authorized_blobs(stream, &batch_fields,
&core_options);
+ }
+ let stream = project_authorized_stream(stream, self.read_type.len());
+ Ok(match self.limit {
+ None => Box::pin(stream),
+ Some(limit) => limit_rows(Box::pin(stream), limit),
+ })
+ }
+
+ fn finish_authorized_blobs(
+ &self,
+ stream: ArrowRecordBatchStream,
+ batch_fields: &[DataField],
+ core_options: &CoreOptions<'_>,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ use super::managed_blob_reader::{resolve_primary_key_blob_stream,
ManagedBlobReadPlan};
+
+ let view_fields = core_options.blob_view_fields();
+ let resolve_views = core_options.blob_view_resolve_enabled()
+ && self.table.rest_env().is_some()
+ && batch_fields.iter().any(|f| view_fields.contains(f.name()));
+ let predicate_views: std::collections::HashSet<_> =
+ super::query_auth::leaf_names(&self.data_predicates)
+ .intersection(&view_fields)
+ .cloned()
+ .collect();
+ let resolve_predicate_views =
+ resolve_views && core_options.blob_as_descriptor() &&
!predicate_views.is_empty();
+ let mut output_views = view_fields.clone();
+ if resolve_predicate_views {
+ output_views.retain(|field| !predicate_views.contains(field));
+ }
+ // Descriptor mode still translates view references to upstream
+ // descriptors. Query predicates must see those logical values.
+ let stream = match self.table.rest_env().filter(|_|
resolve_predicate_views) {
+ Some(env) =>
super::data_evolution_reader::resolve_blob_view_stream(
+ stream,
+ predicate_views,
+ env.clone(),
+ ),
+ None => stream,
+ };
+ let mut payload_options = self.table.schema().options().clone();
+ if resolve_views {
+ let mut descriptors = core_options.blob_descriptor_fields();
+ descriptors.extend(view_fields.iter().cloned());
+ let mut descriptors: Vec<_> = descriptors.into_iter().collect();
+ descriptors.sort_unstable();
+ payload_options.insert("blob-descriptor-field".to_string(),
descriptors.join(","));
+ }
+ let payload_options = CoreOptions::new(&payload_options);
+ let blob_plan = ManagedBlobReadPlan::new(
+ &self.read_type,
+ &self.data_predicates,
+ self.table.schema().fields(),
+ &payload_options,
+ );
+ let stream = match &blob_plan {
+ Some(plan) => {
+ // The payload filter owns its widened schema. Drop
authorization
+ // columns before it resolves columns by position.
+ let fields = plan.scan_fields().to_vec();
+ Box::pin(stream.map(move |batch| {
+ let batch = batch?;
+ let indices = fields
+ .iter()
+ .map(|f| batch.schema().index_of(f.name()))
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!("authorized BLOB filter is
missing a column: {e}"),
+ source: None,
+ })?;
+ batch
+ .project(&indices)
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!("failed to project the authorized
batch: {e}"),
+ source: Some(Box::new(e)),
+ })
+ })) as ArrowRecordBatchStream
+ }
+ None => {
+ let predicates = crate::arrow::format::FilePredicates {
+ predicates: self.data_predicates.clone(),
+ row_filter_factory: None,
+ file_fields: self.table.schema().fields().to_vec(),
+ };
+ let fields = batch_fields.to_vec();
+ let stream = stream.map(move |batch| {
+ crate::arrow::residual::filter_record_batch_by_predicates(
+ batch?,
+ &predicates,
+ &fields,
+ )
+ });
+ let stream = project_authorized_stream(Box::pin(stream),
self.read_type.len());
+ match self.limit {
+ Some(limit) => limit_rows(stream, limit),
+ None => stream,
+ }
+ }
+ };
+ let stream = match self
+ .table
+ .rest_env()
+ .filter(|_| resolve_views && !output_views.is_empty())
+ {
+ Some(env) =>
super::data_evolution_reader::resolve_blob_view_stream(
+ stream,
+ output_views,
+ env.clone(),
+ ),
+ None => stream,
+ };
+ Ok(match blob_plan {
+ Some(plan) => plan.finish(
+ stream,
+ &payload_options,
+ self.table.file_io.clone(),
+ self.blob_parallelism,
+ self.limit,
+ ),
+ None => resolve_primary_key_blob_stream(
+ stream,
+ &self.read_type,
+ &payload_options,
+ self.table.file_io.clone(),
+ self.blob_parallelism,
+ None,
+ ),
+ })
+ }
+
+ fn read_splits(
+ &self,
+ data_splits: &[DataSplit],
+ core_options: &CoreOptions<'_>,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ let has_primary_keys = !self.table.schema.primary_keys().is_empty();
let merge_engine = core_options.merge_engine()?;
// Route supported PK merge engines through the split-aware reader.
@@ -1056,11 +1318,11 @@ impl<'a> PaimonTableRead<'a> {
| MergeEngine::Aggregation
)
{
- return self.read_pk_with_blob(data_splits, &core_options);
+ return self.read_pk_with_blob(data_splits, core_options);
}
if core_options.data_evolution_enabled() {
- self.read_with_evolution(data_splits, &core_options)
+ self.read_with_evolution(data_splits, core_options)
} else {
self.read_raw(data_splits)
}
@@ -1244,6 +1506,7 @@ impl<'a> PaimonTableRead<'a> {
self.read_type().to_vec(),
self.data_predicates.clone(),
)
+ .with_blob_as_descriptor(core_options.blob_as_descriptor())
.with_file_index_read_enabled(core_options.file_index_read_enabled())
.with_batch_size(Some(core_options.read_batch_size()?))
.with_blob_parallelism(self.blob_parallelism)
@@ -1263,6 +1526,41 @@ impl<'a> PaimonTableRead<'a> {
}
}
+fn project_authorized_stream(
+ stream: ArrowRecordBatchStream,
+ columns: usize,
+) -> ArrowRecordBatchStream {
+ let projection: Vec<usize> = (0..columns).collect();
+ Box::pin(stream.map(move |batch| {
+ batch?
+ .project(&projection)
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!("failed to project the authorized batch:
{e}"),
+ source: Some(Box::new(e)),
+ })
+ }))
+}
+
+/// Stops once `limit` rows are out, without polling for more.
+fn limit_rows(stream: ArrowRecordBatchStream, limit: usize) ->
ArrowRecordBatchStream {
+ Box::pin(stream::unfold(
+ (stream, 0usize),
+ move |(mut inner, emitted)| async move {
+ if emitted >= limit {
+ return None;
+ }
+ match inner.next().await? {
+ Err(e) => Some((Err(e), (inner, limit))),
+ Ok(batch) => {
+ let batch = batch.slice(0, batch.num_rows().min(limit -
emitted));
+ let emitted = emitted + batch.num_rows();
+ Some((Ok(batch), (inner, emitted)))
+ }
+ }
+ },
+ ))
+}
+
fn prepend_insert_row_kind_stream(
stream: ArrowRecordBatchStream,
schema: Arc<ArrowSchema>,
@@ -2438,16 +2736,81 @@ mod tests {
fn grant_for(table: &Table, restricted: bool) ->
crate::table::query_auth::QueryAuthGrant {
crate::table::query_auth::QueryAuthGrant::new(
- crate::api::AuthTableQueryResponse {
- filter: restricted.then(|| vec!["{}".to_string()]),
- column_masking: None,
- },
table
.query_auth_session()
.expect("a catalog-loaded table has a session"),
+ None,
+ crate::table::query_auth::Rules {
+ filters: if restricted {
+ vec![crate::spec::Predicate::AlwaysTrue]
+ } else {
+ Vec::new()
+ },
+ },
)
}
+ #[tokio::test]
+ async fn
test_a_restricted_read_refuses_a_variant_extraction_that_can_fail() {
+ use crate::spec::{
+ variant_extraction_row, ArrayType, DataField, DataType, IntType,
MapType, Schema,
+ TableSchema, VarCharType, VariantType,
+ };
+ let variant = || DataType::Variant(VariantType::new());
+ let key = || DataType::VarChar(VarCharType::string_type());
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", variant())
+ .column("items", DataType::Array(ArrayType::new(variant())))
+ .column("tags", DataType::Map(MapType::new(key(), variant())))
+ .option("query-auth.enabled", "true")
+ .build()
+ .unwrap();
+ let table = Table {
+ schema: TableSchema::new(0, &schema),
+ ..crate::table::rest_query_auth_table().await
+ };
+ // `variant_get(..., '$.x', 'INT')` pushed into the read, strict or
not.
+ let extraction = |fail_on_error| {
+ let row = variant_extraction_row(
+ true,
+ [(
+ DataType::Int(IntType::new()),
+ "$.x".to_string(),
+ fail_on_error,
+ "UTC".to_string(),
+ )],
+ )
+ .unwrap();
+ DataType::Row(row)
+ };
+ let read = |field: DataField| TableRead::new(&table, vec![field],
Vec::new());
+ let split = split_with_grant(Some(grant_for(&table, true)));
+ // Nested evolution evaluates one inside a collection too.
+ for field in [
+ DataField::new(1, "payload".to_string(), extraction(true)),
+ DataField::new(
+ 2,
+ "items".to_string(),
+ DataType::Array(ArrayType::new(extraction(true))),
+ ),
+ DataField::new(
+ 3,
+ "tags".to_string(),
+ DataType::Map(MapType::new(key(), extraction(true))),
+ ),
+ ] {
+ let name = field.name().to_string();
+ assert!(
+ matches!(read(field).to_arrow(std::slice::from_ref(&split)),
Err(crate::Error::Unsupported { ref message })
+ if message.contains("Variant extraction")),
+ "a strict cast in '{name}' would run on the rows the rules
drop"
+ );
+ }
+ let lenient = DataField::new(1, "payload".to_string(),
extraction(false));
+ assert!(read(lenient).to_arrow(&[split]).is_ok());
+ }
+
#[tokio::test]
async fn test_one_unrestricted_grant_does_not_cover_the_other_splits() {
let table = crate::table::rest_query_auth_table().await;
@@ -2607,17 +2970,51 @@ mod tests {
}
#[tokio::test]
- async fn test_restricted_grant_on_a_split_refuses_the_read() {
+ async fn test_a_restricted_grant_must_cover_every_split_of_the_read() {
let table = crate::table::rest_query_auth_table().await;
let read = TableRead::new(&table, table.schema.fields().to_vec(),
Vec::new());
- let split = split_with_grant(Some(grant_for(&table, true)));
- assert!(
- matches!(
- read.to_arrow(&[split]),
- Err(crate::Error::Unsupported { ref message }) if
message.contains("query-auth.enabled")
- ),
- "a row filter this client cannot apply must refuse the read"
- );
+ let restricted = split_with_grant(Some(grant_for(&table, true)));
+ assert!(read.to_arrow(std::slice::from_ref(&restricted)).is_ok());
+ let unrestricted = split_with_grant(Some(grant_for(&table, false)));
+ for splits in [
+ [restricted.clone(), unrestricted.clone()],
+ [unrestricted, restricted.clone()],
+ ] {
+ assert!(
+ matches!(read.to_arrow(&splits), Err(crate::Error::Unsupported
{ ref message })
+ if message.contains("different rules")),
+ "one rule set per read, whatever the order"
+ );
+ }
+ // Streaming primary-key splits are read raw.
+ assert!(matches!(
+ read.to_arrow_with_row_kind(&[restricted]),
+ Err(crate::Error::Unsupported { ref message }) if
message.contains("row-kind")
+ ));
+ }
+
+ #[tokio::test]
+ async fn test_a_read_stays_inside_the_authorized_columns() {
+ let table = crate::table::rest_query_auth_table().await;
+ let fields = table.schema.fields().to_vec();
+ // Without rules too: a column permission scoped the grant all the
same.
+ for filters in [vec![Predicate::AlwaysTrue], Vec::new()] {
+ // Planned with no columns, as `COUNT(*)` is.
+ let grant = crate::table::query_auth::QueryAuthGrant::new(
+ table.query_auth_session().unwrap(),
+ Some(Vec::new()),
+ crate::table::query_auth::Rules { filters },
+ );
+ let split = split_with_grant(Some(grant));
+ let narrow = TableRead::new(&table, Vec::new(), Vec::new());
+ assert!(narrow.to_arrow(std::slice::from_ref(&split)).is_ok());
+ let wide = TableRead::new(&table, fields.clone(), Vec::new());
+ assert!(
+ matches!(wide.to_arrow(&[split]),
Err(crate::Error::Unsupported { ref message })
+ if message.contains("outside the columns")),
+ "the server ruled on the planned columns only"
+ );
+ }
}
#[tokio::test]
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index 6cbf089c..edd67a42 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -1538,30 +1538,40 @@ impl<'a> PaimonTableScan<'a> {
/// `scan.snapshot-id` / `scan.tag-name` handling.
pub async fn plan(&self) -> crate::Result<Plan> {
let grant = self.authorize_query().await?;
- let data_evolution_read_field_ids = self.projected_read_field_ids()?;
- let plan = match
super::time_travel::resolve_snapshot(self.table).await? {
+ let restricted = grant
+ .as_deref()
+ .filter(|grant| !grant.is_unrestricted())
+ .map(|grant| self.restricted_by(grant));
+ let scan = restricted.as_ref().unwrap_or(self);
+ let data_evolution_read_field_ids = scan.projected_read_field_ids()?;
+ let plan = match
super::time_travel::resolve_snapshot(scan.table).await? {
Some(snapshot) => {
- self.plan_snapshot(snapshot,
data_evolution_read_field_ids.as_ref(), None)
+ scan.plan_snapshot(snapshot,
data_evolution_read_field_ids.as_ref(), None)
.await?
}
None => Plan::new(Vec::new()),
};
- self.check_planned_files(&plan, grant.is_some()).await?;
+ scan.check_planned_files(&plan, grant.is_some()).await?;
Ok(plan.planned(grant))
}
/// Plan the full scan and return metadata-pruning trace counters.
pub async fn plan_with_trace(&self) -> crate::Result<(Plan, ScanTrace)> {
let grant = self.authorize_query().await?;
+ let restricted = grant
+ .as_deref()
+ .filter(|grant| !grant.is_unrestricted())
+ .map(|grant| self.restricted_by(grant));
+ let scan = restricted.as_ref().unwrap_or(self);
let mut trace = ScanTrace {
- limit: self.limit,
+ limit: scan.limit,
..Default::default()
};
- let data_evolution_read_field_ids = self.projected_read_field_ids()?;
- let plan = match
super::time_travel::resolve_snapshot(self.table).await? {
+ let data_evolution_read_field_ids = scan.projected_read_field_ids()?;
+ let plan = match
super::time_travel::resolve_snapshot(scan.table).await? {
Some(snapshot) => {
trace.snapshot_id = Some(snapshot.id());
- let plan = self
+ let plan = scan
.plan_snapshot(
snapshot,
data_evolution_read_field_ids.as_ref(),
@@ -1573,10 +1583,67 @@ impl<'a> PaimonTableScan<'a> {
}
None => Plan::new(Vec::new()),
};
- self.check_planned_files(&plan, grant.is_some()).await?;
+ scan.check_planned_files(&plan, grant.is_some()).await?;
Ok((plan.planned(grant), trace))
}
+ /// The scan a restricted grant plans: a limit cannot count rows the row
+ /// filter drops.
+ fn restricted_by(&self, grant: &super::query_auth::QueryAuthGrant) -> Self
{
+ let rules = grant.rules();
+ let mut scan = self.clone();
+ if !rules.filters.is_empty() {
+ scan.limit = scan.limit.filter(|limit| *limit == 0);
+ }
+ // Column-slice pruning must keep the files holding the filter's
columns.
+ if let Some(ids) = scan.projected_read_field_ids.as_mut() {
+ let fields = self.table.schema().fields();
+ ids.extend(
+ rules
+ .filter_columns()
+ .into_iter()
+ .filter_map(|i| fields.get(i))
+ .map(|f| f.id()),
+ );
+ }
+ scan
+ }
+
+ /// Java's `select`: the projection plus what the filters read; `None` (the
+ /// whole table) without a projection.
+ fn query_auth_select(&self) -> Option<Vec<String>> {
+ let ids = self.projected_read_field_ids.as_ref()?;
+ let mut select: Vec<String> = self
+ .table
+ .schema()
+ .fields()
+ .iter()
+ .filter(|f| ids.contains(&f.id()))
+ .map(|f| f.name().to_string())
+ .collect();
+ for key in self.partition_filter_columns() {
+ if !select.contains(&key) {
+ select.push(key);
+ }
+ }
+ Some(select)
+ }
+
+ /// Columns the caller's partition filter reads; a pinned partition set
reads them all.
+ fn partition_filter_columns(&self) -> Vec<String> {
+ match &self.partition_filter {
+ None => Vec::new(),
+ Some(PartitionFilter::Predicate(predicate)) => {
+ super::query_auth::leaf_names(std::slice::from_ref(predicate))
+ .into_iter()
+ .collect()
+ }
+ Some(PartitionFilter::PartitionSet { .. }) => {
+ self.table.schema().partition_keys().to_vec()
+ }
+ }
+ }
+
/// The grant predates the manifest read, so the table can have been
/// re-created in between. Also refuses stats the schema no longer covers.
async fn check_planned_files(&self, plan: &Plan, query_auth: bool) ->
crate::Result<()> {
@@ -1626,15 +1693,22 @@ impl<'a> PaimonTableScan<'a> {
if query_auth && touches_row_id {
super::query_auth::reject_system_columns([ROW_ID_FIELD_NAME])?;
}
-
- let grant = self.table.authorize_read(query_auth).await?;
- // A plan already answers COUNT/MIN/MAX from row counts and bounds.
- if grant.as_ref().is_some_and(|g| !g.is_unrestricted()) {
- return Err(super::query_auth::unsupported(
- "a plan already carries file paths, row counts and column
bounds that a row \
- filter or column masking must not expose",
- ));
+ // Pruning resolves leaves by index; global indexes and the partition
scope by name.
+ if query_auth {
+ let schema = self.table.schema();
+
super::query_auth::reject_noncanonical_leaves(&self.data_predicates,
schema.fields())?;
+ if let Some(PartitionFilter::Predicate(predicate)) =
&self.partition_filter {
+ super::query_auth::reject_noncanonical_leaves(
+ std::slice::from_ref(predicate),
+ &schema.partition_fields(),
+ )?;
+ }
}
+
+ let grant = self
+ .table
+ .authorize_read(query_auth, self.query_auth_select())
+ .await?;
Ok(grant)
}
diff --git a/crates/paimon/tests/mock_server.rs
b/crates/paimon/tests/mock_server.rs
index 68dadf34..fd2082ac 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -89,6 +89,8 @@ struct MockState {
drop_policy_error: Option<ErrorResponse>,
auth_responses: HashMap<String, AuthTableQueryResponse>,
column_auth: HashMap<String, Vec<String>>,
+ /// The `select` of every auth request, per `"{db}.{table}"`.
+ auth_selects: HashMap<String, Vec<Option<Vec<String>>>>,
uuid_after_auth: HashMap<String, String>,
uuid_after_calls: HashMap<String, (String, usize)>,
/// ECS metadata role name (for token loader testing)
@@ -871,8 +873,12 @@ impl RESTServer {
Extension(state): Extension<Arc<RESTServer>>,
Json(request): Json<paimon::api::AuthTableQueryRequest>,
) -> impl IntoResponse {
- let s = state.inner.lock().unwrap();
+ let mut s = state.inner.lock().unwrap();
let key = format!("{db}.{table}");
+ s.auth_selects
+ .entry(key.clone())
+ .or_default()
+ .push(request.select.clone());
// Mirrors the reference server: a null select means the real schema
// fields, and any column outside the grant denies the query.
@@ -938,6 +944,14 @@ impl RESTServer {
s.column_auth.insert(format!("{database}.{table}"), columns);
}
+ pub fn auth_selects(&self, database: &str, table: &str) ->
Vec<Option<Vec<String>>> {
+ let s = self.inner.lock().unwrap();
+ s.auth_selects
+ .get(&format!("{database}.{table}"))
+ .cloned()
+ .unwrap_or_default()
+ }
+
/// Handle DELETE /databases/:db/tables/:table - drop a table.
pub async fn drop_table(
Path((db, table)): Path<(String, String)>,
diff --git a/crates/paimon/tests/rest_catalog_test.rs
b/crates/paimon/tests/rest_catalog_test.rs
index a7180132..927f4136 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -36,7 +36,7 @@ use paimon::catalog::{Catalog, Function, FunctionDefinition,
Identifier, RESTCat
use paimon::common::Options;
use paimon::spec::{
BigIntType, BlobType, BlobViewStruct, DataField, DataType, Datum, IntType,
PartitionStatistics,
- PredicateBuilder, Schema, SchemaChange, VarCharType,
+ Predicate, PredicateBuilder, PredicateOperator, Schema, SchemaChange,
VarCharType,
};
use paimon::{CatalogOptions, FileSystemCatalog, Table};
@@ -1119,6 +1119,119 @@ async fn
test_blob_view_limit_only_resolves_selected_references() {
(3, "Repeated again".to_string(), Some(b"bob".to_vec())),
]
);
+
+ // The same selection must protect view lookups under server authorization,
+ // including the multi-provider read after the partial-column updates.
+ server.add_table_with_schema(
+ "default",
+ "blob_view_target",
+ blob_schema(&[
+ ("blob-view-field", "picture"),
+ ("query-auth.enabled", "true"),
+ ]),
+ view.location(),
+ );
+ server.set_auth_response(
+ "default",
+ "blob_view_target",
+ rules(&[int_leaf(0, "id", "LESS_THAN", 4)], &[]),
+ );
+ let authorized = rest_catalog.get_table(&view_id).await.unwrap();
+ assert_eq!(
+
collect_blob_rows(&read_all(&authorized.new_read_builder()).await.unwrap()),
+ vec![
+ (1, "Updated".to_string(), Some(b"alice".to_vec())),
+ (2, "Repeated".to_string(), None),
+ (3, "Repeated again".to_string(), Some(b"bob".to_vec())),
+ ]
+ );
+ let plan = authorized
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap();
+ let mut filtered = authorized.new_read_builder();
+ filtered.with_projection(&["id"]).unwrap().with_filter(
+ PredicateBuilder::new(authorized.schema().fields())
+ .equal("picture", Datum::Bytes(b"alice".to_vec()))
+ .unwrap(),
+ );
+ let rows: Vec<RecordBatch> = filtered
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(ids(&rows), vec![1]);
+
+ // Descriptor reads still translate a view reference before user
predicates.
+ let descriptors = authorized.copy_with_options(HashMap::from([(
+ "blob-as-descriptor".to_string(),
+ "true".to_string(),
+ )]));
+ let batches = read_all(&descriptors.new_read_builder()).await.unwrap();
+ let descriptor = collect_blob_rows(&batches)
+ .into_iter()
+ .find(|row| row.0 == 1)
+ .unwrap()
+ .2
+ .unwrap();
+ paimon::spec::BlobDescriptor::deserialize(&descriptor).unwrap();
+ let plan = descriptors
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap();
+ let mut filtered = descriptors.new_read_builder();
+ filtered.with_projection(&["id"]).unwrap().with_filter(
+ PredicateBuilder::new(descriptors.schema().fields())
+ .equal("picture", Datum::Bytes(descriptor.clone()))
+ .unwrap(),
+ );
+ let rows: Vec<RecordBatch> = filtered
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(ids(&rows), vec![1]);
+
+ let mut filtered = descriptors.new_read_builder();
+ filtered.with_filter(
+ PredicateBuilder::new(descriptors.schema().fields())
+ .equal("picture", Datum::Bytes(descriptor))
+ .unwrap(),
+ );
+ assert_eq!(ids(&read_all(&filtered).await.unwrap()), vec![1]);
+
+ // A view reference and its upstream BLOB can disagree on nullness.
+ // Until rules can resolve that dependency safely, reject instead of
+ // authorizing rows by the reference bytes.
+ for function in ["IS_NULL", "IS_NOT_NULL"] {
+ server.set_auth_response(
+ "default",
+ "blob_view_target",
+ rules(
+ &[serde_json::json!({
+ "kind": "LEAF",
+ "transform": {"name": "FIELD_REF", "fieldRef": {
+ "index": 2, "name": "picture", "type": "BLOB",
+ }},
+ "function": function, "literals": [],
+ })],
+ &[],
+ ),
+ );
+ assert!(matches!(read_all(&authorized.new_read_builder()).await,
+ Err(paimon::Error::Unsupported { message })
+ if message.contains("row filter reads resolving BLOB view")));
+ }
}
#[cfg(not(windows))]
@@ -2803,6 +2916,115 @@ async fn test_query_auth_unrestricted_user_can_read() {
);
}
+/// Authorization must select rows before output or predicate BLOB payloads
open.
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_filters_before_resolving_blob_payloads() {
+ let source = tempfile::tempdir().unwrap();
+ let good_uri = format!("file://{}/allowed.blob", source.path().display());
+ std::fs::write(source.path().join("allowed.blob"), b"good").unwrap();
+ let good = paimon::spec::BlobDescriptor::new(good_uri, 0, 4).serialize();
+ let missing = paimon::spec::BlobDescriptor::new(
+ format!("file://{}/denied.blob", source.path().display()),
+ 0,
+ 4,
+ )
+ .serialize();
+ for primary_key in [true, false] {
+ let batch = RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![1, 2, 3])) as
arrow_array::ArrayRef,
+ ),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from(vec![
+ None,
+ Some(missing.as_slice()),
+ Some(good.as_slice()),
+ ])) as arrow_array::ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let g = written(
+ "auth_blob_order",
+ |options| {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::with_nullable(false)))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .option("blob-descriptor-field", "payload");
+ if primary_key {
+ builder = builder.primary_key(["id"]).option("bucket",
"1");
+ } else {
+ builder = builder
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true");
+ }
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+ },
+ vec![batch],
+ )
+ .await;
+ g.ctx.server.set_auth_response(
+ "default",
+ "auth_blob_order",
+ rules(&[int_leaf(0, "id", "NOT_EQUAL", 2)], &[]),
+ );
+
+ let mut id_only = g.table.new_read_builder();
+ id_only.with_projection(&["id"]).unwrap();
+ assert_eq!(ids(&read_all(&id_only).await.unwrap()), vec![1, 3]);
+
+ let full = read_all(&g.table.new_read_builder()).await.unwrap();
+ assert_eq!(ids(&full), vec![1, 3]);
+ for batch in &full {
+ let id = column::<Int32Array>(batch, "id");
+ let payload = column::<LargeBinaryArray>(batch, "payload");
+ for row in 0..batch.num_rows() {
+ if id.value(row) == 1 {
+ assert!(payload.is_null(row));
+ } else {
+ assert_eq!(payload.value(row), b"good");
+ }
+ }
+ }
+
+ let mut limited = g.table.new_read_builder();
+ limited.with_limit(1);
+ assert_eq!(
+ read_all(&limited)
+ .await
+ .unwrap()
+ .iter()
+ .map(RecordBatch::num_rows)
+ .sum::<usize>(),
+ 1
+ );
+
+ // Even when the payload is not returned, its predicate must run after
auth.
+ // Plan the complete column scope, then read the narrower result.
+ let plan = g.table.new_read_builder().new_scan().plan().await.unwrap();
+ let mut filtered = g.table.new_read_builder();
+ filtered.with_projection(&["id"]).unwrap().with_filter(
+ PredicateBuilder::new(g.table.schema().fields())
+ .equal("payload", Datum::Bytes(b"good".to_vec()))
+ .unwrap(),
+ );
+ let result: Vec<RecordBatch> = filtered
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(ids(&result), vec![3]);
+ }
+}
+
fn schema_of(columns: &[&str], options: &[(&str, &str)]) -> Schema {
let mut builder = Schema::builder();
for name in columns {
@@ -2866,27 +3088,64 @@ fn assert_drifted(err: paimon::Error, what: &str) {
);
}
-fn restricted() -> paimon::api::AuthTableQueryResponse {
+/// A row filter of `filters` (ANDed) and `masks` by column, as the server
sends them.
+fn rules(
+ filters: &[serde_json::Value],
+ masks: &[(&str, serde_json::Value)],
+) -> paimon::api::AuthTableQueryResponse {
paimon::api::AuthTableQueryResponse {
- filter: Some(vec!["{\"field\":\"id\"}".to_string()]),
- column_masking: None,
+ filter: (!filters.is_empty()).then(|| filters.iter().map(|f|
f.to_string()).collect()),
+ column_masking: (!masks.is_empty()).then(|| {
+ masks
+ .iter()
+ .map(|(column, mask)| (column.to_string(), mask.to_string()))
+ .collect()
+ }),
}
}
-#[tokio::test]
-async fn test_query_auth_restricted_user_is_refused_at_plan_time() {
- let g = guarded("restricted", &["id"]).await;
- g.ctx
- .server
- .set_auth_response("default", "restricted", restricted());
-
- assert_refused(
- plan_err(
- &g.table,
- "a restricted user must be refused before a plan exists",
- )
- .await,
- );
+/// Java `LeafPredicate` JSON over an INT column.
+fn int_leaf(index: usize, column: &str, function: &str, literal: i32) ->
serde_json::Value {
+ serde_json::json!({
+ "kind": "LEAF",
+ "transform": {
+ "name": "FIELD_REF",
+ "fieldRef": {"index": index, "name": column, "type": "INT"},
+ },
+ "function": function,
+ "literals": [literal],
+ })
+}
+
+#[tokio::test]
+async fn
test_query_auth_rules_this_client_cannot_parse_fail_closed_at_planning() {
+ let g = guarded("unparseable", &["id"]).await;
+ let widened = serde_json::json!({
+ "name": "CAST",
+ "fieldRef": {"index": 0, "name": "id", "type": "INT"},
+ "type": "BIGINT",
+ });
+ for response in [
+ // Not a predicate, or nothing at all.
+ rules(&[serde_json::json!({"field": "id"})], &[]),
+ paimon::api::AuthTableQueryResponse {
+ filter: Some(vec![String::new()]),
+ column_masking: None,
+ },
+ // Functions and transforms this client does not know.
+ rules(&[int_leaf(0, "id", "REGEXP", 1)], &[]),
+ rules(&[], &[("id", serde_json::json!({"name": "invalid"}))]),
+ // Columns the table does not have.
+ rules(&[int_leaf(0, "missing", "EQUAL", 1)], &[]),
+ rules(&[], &[("missing", serde_json::json!({"name": "NULL"}))]),
+ // A mask must keep its column's type.
+ rules(&[], &[("id", widened)]),
+ ] {
+ g.ctx
+ .server
+ .set_auth_response("default", "unparseable", response.clone());
+ assert_refused(plan_err(&g.table, &format!("{response:?}")).await);
+ }
}
#[tokio::test]
@@ -3139,6 +3398,509 @@ async fn test_query_auth_refuses_an_assembled_handle() {
}
}
+fn people(options: &[(&str, &str)]) -> Schema {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("name", DataType::VarChar(VarCharType::new(255).unwrap()));
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+}
+
+fn people_batch(rows: &[(i32, &str)]) -> RecordBatch {
+ RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, true),
+ ArrowField::new("name", ArrowDataType::Utf8, true),
+ ])),
+ vec![
+ Arc::new(Int32Array::from_iter_values(rows.iter().map(|r| r.0))),
+ Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.1))),
+ ],
+ )
+ .unwrap()
+}
+
+const NAMES: [&str; 10] = [
+ "alice", "bob", "carol", "dave", "erin", "frank", "grace", "heidi",
"ivan", "judy",
+];
+
+/// Ids `ids`, each with its name from [`NAMES`].
+fn people_rows(ids: std::ops::RangeInclusive<i32>) -> RecordBatch {
+ let rows: Vec<(i32, &str)> = ids.map(|id| (id, NAMES[id as usize -
1])).collect();
+ people_batch(&rows)
+}
+
+/// Writes one commit per batch through a filesystem catalog, then serves the
+/// files as a `query-auth.enabled` table.
+async fn written(
+ name: &str,
+ schema: impl Fn(&[(&str, &str)]) -> Schema,
+ commits: Vec<RecordBatch>,
+) -> Guarded {
+ let tmp = tempfile::tempdir().unwrap();
+ let mut fs_options = Options::new();
+ fs_options.set(
+ CatalogOptions::WAREHOUSE,
+ format!("file://{}", tmp.path().display()),
+ );
+ let fs_catalog = FileSystemCatalog::new(fs_options).unwrap();
+ fs_catalog
+ .create_database("default", true, HashMap::new())
+ .await
+ .unwrap();
+ let identifier = Identifier::new("default", name);
+ fs_catalog
+ .create_table(&identifier, schema(&[]), false)
+ .await
+ .unwrap();
+ let plain = fs_catalog.get_table(&identifier).await.unwrap();
+ for batch in commits {
+ write_batch(&plain, batch, "query-auth-fixture").await;
+ }
+
+ let ctx = setup_catalog(vec!["default"]).await;
+ ctx.server
+ .add_table_with_schema("default", name, schema(GUARDED),
plain.location());
+ let table = ctx.catalog.get_table(&identifier).await.unwrap();
+ Guarded {
+ ctx,
+ table,
+ identifier,
+ _tmp: tmp,
+ }
+}
+
+async fn read_all(builder: &paimon::table::ReadBuilder<'_>) ->
paimon::Result<Vec<RecordBatch>> {
+ let plan = builder.new_scan().plan().await?;
+ builder
+ .new_read()?
+ .to_arrow(plan.splits())?
+ .try_collect()
+ .await
+}
+
+fn column<'b, T: 'static>(batch: &'b RecordBatch, name: &str) -> &'b T {
+ batch
+ .column_by_name(name)
+ .unwrap_or_else(|| panic!("no column '{name}'"))
+ .as_any()
+ .downcast_ref::<T>()
+ .unwrap()
+}
+
+fn ids(batches: &[RecordBatch]) -> Vec<i32> {
+ let mut ids: Vec<i32> = batches
+ .iter()
+ .flat_map(|b| column::<Int32Array>(b, "id").values().to_vec())
+ .collect();
+ ids.sort_unstable();
+ ids
+}
+
+/// `(id, name)` sorted by id; a null name is `None`.
+fn id_names(batches: &[RecordBatch]) -> Vec<(i32, Option<String>)> {
+ let mut rows: Vec<_> = batches
+ .iter()
+ .flat_map(|b| {
+ let ids = column::<Int32Array>(b, "id");
+ let names = column::<StringArray>(b, "name");
+ (0..b.num_rows())
+ .map(|r| {
+ (
+ ids.value(r),
+ names.is_valid(r).then(|| names.value(r).to_string()),
+ )
+ })
+ .collect::<Vec<_>>()
+ })
+ .collect();
+ rows.sort();
+ rows
+}
+
+#[track_caller]
+fn assert_forbidden(err: paimon::Error) {
+ assert!(
+ matches!(
+ err,
+ paimon::Error::RestApi {
+ source: paimon::api::RestError::Forbidden { .. }
+ }
+ ),
+ "{err:?}"
+ );
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_row_filter_returns_only_the_rows_it_admits() {
+ // One split per commit, so a pushed-down limit could stop after the first.
+ let g = written(
+ "filtered",
+ |options| people(&[options, &[("source.split.target-size",
"1b")]].concat()),
+ vec![people_rows(1..=5), people_rows(6..=10)],
+ )
+ .await;
+ g.ctx.server.set_auth_response(
+ "default",
+ "filtered",
+ rules(&[int_leaf(0, "id", "GREATER_THAN", 6)], &[]),
+ );
+
+ let rows = id_names(&read_all(&g.table.new_read_builder()).await.unwrap());
+ let expected: Vec<_> = (7..=10)
+ .map(|id| (id, Some(NAMES[id as usize - 1].to_string())))
+ .collect();
+ assert_eq!(rows, expected);
+
+ // What COUNT(*) reads: no columns, only the admitted rows.
+ let mut counted = g.table.new_read_builder();
+ counted.with_projection(&[]).unwrap();
+ let batches = read_all(&counted).await.unwrap();
+ assert!(batches.iter().all(|b| b.num_columns() == 0));
+ assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 4);
+
+ let mut limited = g.table.new_read_builder();
+ limited.with_limit(2);
+ let plan = limited.new_scan().plan().await.unwrap();
+ assert!(plan.splits().iter().all(|s| s.has_query_auth_rules()));
+ assert_eq!(
+ plan.splits().len(),
+ 2,
+ "the row filter must disable limit pushdown"
+ );
+ let batches: Vec<RecordBatch> = limited
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let limited_ids = ids(&batches);
+ assert_eq!(limited_ids.len(), 2, "{limited_ids:?}");
+ assert!(limited_ids.iter().all(|id| *id > 6), "{limited_ids:?}");
+}
+
+/// Its filters pick their own columns and run before any rule; an expression
+/// failing on a dropped row would put the value in its error.
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_refuses_an_engine_decoder_filter() {
+ #[derive(Debug)]
+ struct EngineFilter;
+
+ impl paimon::arrow::RowFilterFactory for EngineFilter {
+ fn create(
+ &self,
+ _context: paimon::arrow::RowFilterContext<'_>,
+ ) -> paimon::Result<Vec<Box<dyn paimon::arrow::RowFilter>>> {
+ Ok(Vec::new())
+ }
+ }
+
+ let g = written("decoded", people, vec![people_rows(1..=10)]).await;
+ let mut id_only = g.table.new_read_builder();
+ id_only.with_projection(&["id"]).unwrap();
+ // Under a row filter, and under a column permission alone.
+ g.ctx.server.set_auth_response(
+ "default",
+ "decoded",
+ rules(&[int_leaf(0, "id", "GREATER_THAN", 6)], &[]),
+ );
+ let under_rules = id_only.new_scan().plan().await.unwrap();
+ g.ctx
+ .server
+ .set_auth_response("default", "decoded", rules(&[], &[]));
+ g.ctx
+ .server
+ .set_column_auth("default", "decoded", vec!["id".to_string()]);
+ let under_permission = id_only.new_scan().plan().await.unwrap();
+
+ for plan in [under_permission, under_rules] {
+ let Err(err) = id_only
+ .new_read()
+ .unwrap()
+ .with_row_filter_factory(Arc::new(EngineFilter))
+ .to_arrow(plan.splits())
+ else {
+ panic!("the decoder filter would run outside the server's grant");
+ };
+ assert!(
+ matches!(err, paimon::Error::Unsupported { ref message }
+ if message.contains("decoder filter would run outside the
server's grant")),
+ "{err:?}"
+ );
+ }
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn
test_query_auth_asks_about_the_projection_and_the_columns_filters_read() {
+ let g = written("scoped", people, vec![people_rows(1..=3)]).await;
+ g.ctx
+ .server
+ .set_column_auth("default", "scoped", vec!["id".to_string()]);
+
+ let mut id_only = g.table.new_read_builder();
+ id_only.with_projection(&["id"]).unwrap();
+ assert_eq!(ids(&read_all(&id_only).await.unwrap()), vec![1, 2, 3]);
+
+ let mut with_name = g.table.new_read_builder();
+ with_name.with_projection(&["id", "name"]).unwrap();
+ assert_forbidden(read_all(&with_name).await.unwrap_err());
+
+ let mut filtered = g.table.new_read_builder();
+ filtered.with_projection(&["id"]).unwrap().with_filter(
+ PredicateBuilder::new(g.table.schema().fields())
+ .equal("name", Datum::String("bob".to_string()))
+ .unwrap(),
+ );
+ assert_forbidden(read_all(&filtered).await.unwrap_err());
+
+ assert_forbidden(read_all(&g.table.new_read_builder()).await.unwrap_err());
+
+ // The order of a select is not part of what it asks.
+ let asked: Vec<Option<std::collections::BTreeSet<String>>> = g
+ .ctx
+ .server
+ .auth_selects("default", "scoped")
+ .into_iter()
+ .map(|select| select.map(|columns| columns.into_iter().collect()))
+ .collect();
+ let select = |columns: &[&str]| Some(columns.iter().map(|c|
c.to_string()).collect());
+ assert_eq!(
+ asked,
+ vec![
+ select(&["id"]),
+ select(&["id", "name"]),
+ select(&["id", "name"]),
+ None
+ ]
+ );
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_refuses_a_read_wider_than_its_plan() {
+ let g = written("narrow", people, vec![people_rows(1..=10)]).await;
+ let mut planned = g.table.new_read_builder();
+ planned.with_projection(&["id"]).unwrap();
+ let mut filtered = g.table.new_read_builder();
+ filtered.with_projection(&["id"]).unwrap().with_filter(
+ PredicateBuilder::new(g.table.schema().fields())
+ .equal("name", Datum::String("bob".to_string()))
+ .unwrap(),
+ );
+ let wider = [g.table.new_read_builder(), filtered];
+
+ // Scoped once under a row filter, once by a column permission alone.
+ g.ctx.server.set_auth_response(
+ "default",
+ "narrow",
+ rules(&[int_leaf(0, "id", "GREATER_THAN", 6)], &[]),
+ );
+ let under_rules = planned.new_scan().plan().await.unwrap();
+ g.ctx
+ .server
+ .set_auth_response("default", "narrow", rules(&[], &[]));
+ g.ctx
+ .server
+ .set_column_auth("default", "narrow", vec!["id".to_string()]);
+ let under_permission = planned.new_scan().plan().await.unwrap();
+
+ for (plan, admitted) in [(under_rules, 4), (under_permission, 10)] {
+ let read: Vec<RecordBatch> = planned
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(ids(&read).len(), admitted);
+ for builder in &wider {
+ let Err(err) = builder.new_read().unwrap().to_arrow(plan.splits())
else {
+ panic!("'name' was never authorized");
+ };
+ assert!(
+ matches!(err, paimon::Error::Unsupported { ref message }
+ if message.contains("query-auth.enabled")
+ && message.contains("outside the columns")),
+ "{err:?}"
+ );
+ }
+ }
+}
+
+/// A leaf `"id"` built on `score`'s index, or on `id` with another type.
+fn disagreeing_leaves() -> [Predicate; 2] {
+ let leaf = |index: usize, data_type: DataType, literal: Datum|
Predicate::Leaf {
+ column: "id".to_string(),
+ index,
+ data_type,
+ op: PredicateOperator::Eq,
+ literals: vec![literal],
+ };
+ [
+ leaf(2, DataType::Int(IntType::new()), Datum::Int(20)),
+ leaf(0, DataType::BigInt(BigIntType::new()), Datum::Long(2)),
+ ]
+}
+
+fn assert_disagreeing_leaf(err: paimon::Error) {
+ assert!(
+ matches!(err, paimon::Error::Unsupported { ref message }
+ if message.contains("query-auth.enabled")
+ && message.contains("another field by index or type")),
+ "{err:?}"
+ );
+}
+
+/// Readers resolve a leaf by its index, so its name must not stand in for it.
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_refuses_a_filter_whose_name_and_index_disagree() {
+ let g = scores().await;
+ g.ctx
+ .server
+ .set_column_auth("default", "scores", vec!["id".to_string()]);
+ let mut planned = g.table.new_read_builder();
+ planned.with_projection(&["id"]).unwrap();
+ let plan = planned.new_scan().plan().await.unwrap();
+
+ for leaf in disagreeing_leaves() {
+ let mut filtered = g.table.new_read_builder();
+ filtered.with_projection(&["id"]).unwrap().with_filter(leaf);
+ // Planned clean, read with the leaf.
+ let Err(err) = filtered.new_read().unwrap().to_arrow(plan.splits())
else {
+ panic!("the leaf reads a column no grant covered");
+ };
+ assert_disagreeing_leaf(err);
+ assert_disagreeing_leaf(read_all(&filtered).await.unwrap_err());
+ }
+}
+
+/// A partition-only conjunct prunes by index and never reaches the read.
+#[cfg(not(windows))]
+#[tokio::test]
+async fn
test_query_auth_refuses_a_partition_filter_whose_name_and_index_disagree() {
+ let day = || DataType::VarChar(VarCharType::new(10).unwrap());
+ let schema = |options: &[(&str, &str)]| {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("dt", day())
+ .partition_keys(["dt"]);
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+ };
+ let batch = RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, true),
+ ArrowField::new("dt", ArrowDataType::Utf8, true),
+ ])),
+ vec![
+ Arc::new(Int32Array::from(vec![1, 2])),
+ Arc::new(StringArray::from(vec!["a", "b"])),
+ ],
+ )
+ .unwrap();
+ let g = written("by_day", schema, vec![batch]).await;
+ g.ctx
+ .server
+ .set_column_auth("default", "by_day", vec!["id".to_string()]);
+
+ // A range stays a predicate; an equality would pin every partition key.
+ let mut filtered = g.table.new_read_builder();
+ filtered
+ .with_projection(&["id"])
+ .unwrap()
+ .with_filter(Predicate::Leaf {
+ column: "id".to_string(),
+ index: 1,
+ data_type: day(),
+ op: PredicateOperator::Gt,
+ literals: vec![Datum::String("a".to_string())],
+ });
+ assert_disagreeing_leaf(read_all(&filtered).await.unwrap_err());
+}
+
+/// A primary-key table whose second commit updates keys 1 and 2.
+async fn scores() -> Guarded {
+ let schema = |options: &[(&str, &str)]| {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::with_nullable(false)))
+ .column("name", DataType::VarChar(VarCharType::new(255).unwrap()))
+ .column("score", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("bucket", "1");
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+ };
+ let batch = |rows: &[(i32, &str, i32)]| {
+ RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("name", ArrowDataType::Utf8, true),
+ ArrowField::new("score", ArrowDataType::Int32, true),
+ ])),
+ vec![
+ Arc::new(Int32Array::from_iter_values(rows.iter().map(|r|
r.0))),
+ Arc::new(StringArray::from_iter_values(rows.iter().map(|r|
r.1))),
+ Arc::new(Int32Array::from_iter_values(rows.iter().map(|r|
r.2))),
+ ],
+ )
+ .unwrap()
+ };
+ written(
+ "scores",
+ schema,
+ vec![
+ batch(&[(1, "alice", 10), (2, "bob", 50), (3, "carol", 90)]),
+ batch(&[(1, "alicia", 80), (2, "bobby", 20)]),
+ ],
+ )
+ .await
+}
+
+fn score_rows(batches: &[RecordBatch]) -> Vec<(i32, String, i32)> {
+ let mut rows: Vec<(i32, String, i32)> = batches
+ .iter()
+ .flat_map(|b| {
+ let ids = column::<Int32Array>(b, "id");
+ let names = column::<StringArray>(b, "name");
+ let scores = column::<Int32Array>(b, "score");
+ (0..b.num_rows())
+ .map(|r| (ids.value(r), names.value(r).to_string(),
scores.value(r)))
+ .collect::<Vec<_>>()
+ })
+ .collect();
+ rows.sort();
+ rows
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_filters_the_merged_primary_key_row() {
+ let g = scores().await;
+ g.ctx.server.set_auth_response(
+ "default",
+ "scores",
+ rules(&[int_leaf(2, "score", "GREATER_THAN", 30)], &[]),
+ );
+ // Filtering each commit before the merge would bring back bob's first row.
+ assert_eq!(
+ score_rows(&read_all(&g.table.new_read_builder()).await.unwrap()),
+ vec![(1, "alicia".to_string(), 80), (3, "carol".to_string(), 90)]
+ );
+}
+
#[tokio::test]
async fn test_rest_catalog_manages_permissions_end_to_end() {
let ctx = setup_catalog(vec!["default"]).await;
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 6f16a749..428b16e4 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1602,6 +1602,23 @@ When the following conditions are met, `COUNT(*)`
retrieves exact row counts dir
- No LIMIT clause
- Filter predicates only involve partition columns (Exact level)
+### Query Authorization
+
+Row filters cannot reference a resolving BLOB view: the stored reference's
+nullness can differ from its upstream value. Other BLOB payloads and
output-only
+BLOB views are resolved after row authorization.
+
+When a table in a REST catalog has `'query-auth.enabled' = 'true'`, planning a
+query asks the catalog what the current user may read, and the row filters it
+returns (see `create_policy`) are applied to the result:
+
+- Rows are filtered on their stored values.
+- A user the catalog also masks columns for is refused.
+- Statistics are not used under a row filter, which also turns off `LIMIT`
+ pushdown, so such queries read the data.
+- Time travel, branches, system tables, incremental and audit-log reads, and
+ search are refused on such tables.
+
## Python Multimodal Helper Functions
When you use `pypaimon_rust.datafusion.SQLContext`, the Python binding
registers a small set of scalar helper functions for BLOB-backed media and
vector workflows. These helpers are Python-binding built-ins; they are not
registered by the Rust `paimon_datafusion::SQLContext`.