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 aba5b5b [table] Fail closed when reading a query-auth.enabled table
(#447)
aba5b5b is described below
commit aba5b5bcdc7d29265c57cd52adb5b6a9d488fcae
Author: Jiajia Li <[email protected]>
AuthorDate: Sat Jul 4 23:25:27 2026 +0800
[table] Fail closed when reading a query-auth.enabled table (#447)
---
.../datafusion/src/system_tables/mod.rs | 4 ++
.../integrations/datafusion/tests/system_tables.rs | 35 ++++++++++++++++++
crates/paimon/src/spec/core_options.rs | 27 ++++++++++++++
crates/paimon/src/spec/schema.rs | 10 ++++-
.../paimon/src/table/full_text_search_builder.rs | 16 ++++++++
crates/paimon/src/table/mod.rs | 25 +++++++++++++
crates/paimon/src/table/read_builder.rs | 32 +++++++++++++++-
crates/paimon/src/table/table_read.rs | 18 +++++++++
crates/paimon/src/table/table_scan.rs | 43 ++++++++++++++++++++++
crates/paimon/src/table/vector_search_builder.rs | 16 ++++++++
10 files changed, 222 insertions(+), 4 deletions(-)
diff --git a/crates/integrations/datafusion/src/system_tables/mod.rs
b/crates/integrations/datafusion/src/system_tables/mod.rs
index 0305d2f..c46d3cd 100644
--- a/crates/integrations/datafusion/src/system_tables/mod.rs
+++ b/crates/integrations/datafusion/src/system_tables/mod.rs
@@ -139,6 +139,10 @@ pub(crate) async fn load(
let identifier = Identifier::new(database, base.clone());
match catalog.get_table(&identifier).await {
Ok(table) => {
+ // Fail closed: system tables expose file metadata the client
can't authorize.
+ paimon::spec::CoreOptions::new(table.schema().options())
+ .ensure_read_authorized()
+ .map_err(to_datafusion_error)?;
if system_name.eq_ignore_ascii_case("partitions") {
return partitions::build(catalog, identifier, table).map(Some);
}
diff --git a/crates/integrations/datafusion/tests/system_tables.rs
b/crates/integrations/datafusion/tests/system_tables.rs
index ad4805d..be1d13e 100644
--- a/crates/integrations/datafusion/tests/system_tables.rs
+++ b/crates/integrations/datafusion/tests/system_tables.rs
@@ -72,6 +72,41 @@ async fn run_sql(ctx: &SQLContext, sql: &str) ->
Vec<RecordBatch> {
.unwrap_or_else(|e| panic!("Failed to execute `{sql}`: {e}"))
}
+// Error text of `sql`, whether it surfaces at planning or execution.
+async fn query_error(ctx: &SQLContext, sql: &str) -> String {
+ match ctx.sql(sql).await {
+ Err(e) => e.to_string(),
+ Ok(df) => df
+ .collect()
+ .await
+ .expect_err(&format!("`{sql}` must fail"))
+ .to_string(),
+ }
+}
+
+#[tokio::test]
+async fn test_query_auth_table_fails_closed() {
+ let (ctx, _catalog, _tmp) = create_context().await;
+ run_sql(
+ &ctx,
+ "CREATE TABLE paimon.default.qa (id INT) WITH ('query-auth.enabled' =
'true')",
+ )
+ .await;
+
+ // Data reads and data-derived system tables must all fail closed.
+ for sql in [
+ "SELECT * FROM paimon.default.qa",
+ "SELECT * FROM paimon.default.qa$manifests",
+ "SELECT * FROM paimon.default.qa$table_indexes",
+ ] {
+ let err = query_error(&ctx, sql).await;
+ assert!(
+ err.contains("query-auth.enabled"),
+ "`{sql}` should fail closed, got: {err}"
+ );
+ }
+}
+
#[tokio::test]
async fn test_options_system_table() {
let (ctx, catalog, _tmp) = create_context().await;
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index f11298f..f5f71c5 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -18,6 +18,7 @@
use std::collections::{HashMap, HashSet};
const DELETION_VECTORS_ENABLED_OPTION: &str = "deletion-vectors.enabled";
+pub(crate) const QUERY_AUTH_ENABLED_OPTION: &str = "query-auth.enabled";
const DATA_EVOLUTION_ENABLED_OPTION: &str = "data-evolution.enabled";
const GLOBAL_INDEX_ENABLED_OPTION: &str = "global-index.enabled";
const GLOBAL_INDEX_SEARCH_MODE_OPTION: &str = "global-index.search-mode";
@@ -207,6 +208,32 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(false)
}
+ /// Whether `query-auth.enabled` is set.
+ ///
+ /// When set, the server enforces a per-user row filter / column masking
that this client
+ /// can't yet apply, so read paths fail closed (see
`ensure_read_authorized`).
+ pub fn query_auth_enabled(&self) -> bool {
+ self.options
+ .get(QUERY_AUTH_ENABLED_OPTION)
+ .map(|value| value.eq_ignore_ascii_case("true"))
+ .unwrap_or(false)
+ }
+
+ /// Fail closed when `query-auth.enabled` is set: this client can't
enforce the row
+ /// filter / column masking, so refuse to read. Call at every read
boundary (build,
+ /// plan, materialize) so no binding fast-path can bypass it.
+ pub fn ensure_read_authorized(&self) -> crate::Result<()> {
+ if self.query_auth_enabled() {
+ return Err(crate::Error::Unsupported {
+ message: "reading a table with 'query-auth.enabled' = true is
not supported: \
+ the Rust client cannot yet enforce its row-level
auth filter / column \
+ masking, so it refuses to read to avoid returning
unfiltered data"
+ .to_string(),
+ });
+ }
+ Ok(())
+ }
+
/// Returns the user-specified sequence field names, if configured.
/// When set, the values of these columns are used as `_SEQUENCE_NUMBER`
instead of auto-increment.
/// Multiple fields can be comma-separated (e.g. `"col_a,col_b"`).
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index 777b62f..75b35c9 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -16,7 +16,8 @@
// under the License.
use crate::spec::core_options::{
- first_row_supports_changelog_producer, CoreOptions, BUCKET_KEY_OPTION,
SEQUENCE_FIELD_OPTION,
+ first_row_supports_changelog_producer, CoreOptions, BUCKET_KEY_OPTION,
+ QUERY_AUTH_ENABLED_OPTION, SEQUENCE_FIELD_OPTION,
};
use crate::spec::types::{ArrayType, DataType, MapType, MultisetType, RowType};
use crate::spec::{
@@ -130,7 +131,12 @@ impl TableSchema {
}
/// Create a copy of this schema with extra options merged in.
- pub fn copy_with_options(&self, extra: HashMap<String, String>) -> Self {
+ ///
+ /// A stored `query-auth.enabled = true` can't be turned off by a dynamic
override.
+ pub fn copy_with_options(&self, mut extra: HashMap<String, String>) ->
Self {
+ if CoreOptions::new(&self.options).query_auth_enabled() {
+ extra.insert(QUERY_AUTH_ENABLED_OPTION.to_string(),
"true".to_string());
+ }
let mut new_schema = self.clone();
new_schema.options.extend(extra);
new_schema
diff --git a/crates/paimon/src/table/full_text_search_builder.rs
b/crates/paimon/src/table/full_text_search_builder.rs
index b24e82e..616d1ed 100644
--- a/crates/paimon/src/table/full_text_search_builder.rs
+++ b/crates/paimon/src/table/full_text_search_builder.rs
@@ -96,6 +96,8 @@ impl<'a> FullTextSearchBuilder<'a> {
///
/// Reference: `FullTextSearchBuilder.executeLocal()`
pub async fn execute(&self) -> crate::Result<Vec<RowRange>> {
+ // Fail closed: returns data-derived row ranges outside
`TableScan`/`TableRead`.
+
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
let text_column =
self.text_column
.as_deref()
@@ -542,4 +544,18 @@ mod tests {
)]));
RecordBatch::try_new(schema,
vec![Arc::new(StringArray::from(values))]).unwrap()
}
+
+ #[tokio::test]
+ async fn test_execute_fails_closed_when_query_auth_enabled() {
+ let table = crate::table::query_auth_table();
+ let err = table
+ .new_full_text_search_builder()
+ .execute()
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message } if
message.contains("query-auth.enabled")),
+ "full-text search must fail closed for a query-auth table"
+ );
+ }
}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 19e9f00..4a59948 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -282,3 +282,28 @@ pub type ArrowRecordBatchStream = BoxStream<'static,
Result<RecordBatch>>;
pub(crate) fn find_field_id_by_name(fields: &[DataField], name: &str) ->
Option<i32> {
fields.iter().find(|f| f.name() == name).map(|f| f.id())
}
+
+/// A minimal table with `query-auth.enabled = true`, for the fail-closed read
guard.
+#[cfg(test)]
+pub(crate) fn query_auth_table() -> Table {
+ use crate::catalog::Identifier;
+ use crate::io::FileIOBuilder;
+ use crate::spec::{DataType, IntType, Schema, TableSchema};
+
+ let file_io = FileIOBuilder::new("file").build().unwrap();
+ let table_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .option("query-auth.enabled", "true")
+ .build()
+ .unwrap(),
+ );
+ Table::new(
+ file_io,
+ Identifier::new("default", "auth_t"),
+ "/tmp/test-query-auth-table".to_string(),
+ table_schema,
+ None,
+ )
+}
diff --git a/crates/paimon/src/table/read_builder.rs
b/crates/paimon/src/table/read_builder.rs
index dbf22ca..1b21d24 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -228,6 +228,9 @@ impl<'a> ReadBuilder<'a> {
/// Create a table read for consuming splits (e.g. from a scan plan).
pub fn new_read(&self) -> Result<TableRead<'a>> {
+ // Fail closed at read construction so bindings that short-circuit
before
+ // `to_arrow` (e.g. an empty-splits fast path) can't bypass the guard.
+
CoreOptions::new(self.table.schema.options()).ensure_read_authorized()?;
let read_type = match &self.projected_fields {
None => self.table.schema.fields().to_vec(),
Some(projected) => self.resolve_projected_fields(projected)?,
@@ -334,10 +337,10 @@ mod tests {
use crate::spec::{
BinaryRow, DataType, IntType, Predicate, PredicateBuilder, Schema,
TableSchema, VarCharType,
};
- use crate::table::{DataSplitBuilder, Table};
+ use crate::table::{query_auth_table, DataSplitBuilder, Table};
use arrow_array::{Int32Array, RecordBatch};
use futures::TryStreamExt;
- use std::collections::HashSet;
+ use std::collections::{HashMap, HashSet};
use std::fs;
use tempfile::tempdir;
use test_utils::{local_file_path, test_data_file, write_int_parquet_file};
@@ -398,6 +401,31 @@ mod tests {
)
}
+ #[test]
+ fn test_read_fails_closed_when_query_auth_enabled() {
+ let table = query_auth_table();
+ // `new_read` fails closed, so bindings that short-circuit before
`to_arrow` can't bypass.
+ let err = table.new_read_builder().new_read().unwrap_err();
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message } if
message.contains("query-auth.enabled")),
+ "building a read for a query-auth.enabled table must fail closed"
+ );
+ }
+
+ #[test]
+ fn test_dynamic_option_cannot_disable_query_auth() {
+ // Copying the table with the option off must not weaken a stored
`true`.
+ let table = query_auth_table().copy_with_options(HashMap::from([(
+ "query-auth.enabled".to_string(),
+ "false".to_string(),
+ )]));
+ let err = table.new_read_builder().new_read().unwrap_err();
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message } if
message.contains("query-auth.enabled")),
+ "a dynamic override must not disable query-auth"
+ );
+ }
+
#[test]
fn test_projected_read_field_ids_uses_projection_ids() {
let table = simple_table();
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index 6f71455..853ce9c 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -74,6 +74,8 @@ impl<'a> TableRead<'a> {
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 = CoreOptions::new(self.table.schema.options());
+ // Fail closed for a direct `TableRead` (bypassing
`ReadBuilder::new_read`).
+ core_options.ensure_read_authorized()?;
let merge_engine = core_options.merge_engine()?;
// PK table with Deduplicate engine: splits that may hold multiple
@@ -237,6 +239,7 @@ mod tests {
use super::*;
use crate::spec::stats::BinaryTableStats;
use crate::spec::{BinaryRow, DataFileMeta};
+ use crate::table::query_auth_table;
use crate::table::source::DataSplitBuilder;
fn file(name: &str, level: i32, delete_row_count: Option<i64>) ->
DataFileMeta {
@@ -298,4 +301,19 @@ mod tests {
let dv_compacted = split(vec![file("a", 5, None)], false);
assert!(!pk_split_needs_merge(&dv_compacted, true));
}
+
+ #[test]
+ fn test_direct_table_read_fails_closed_when_query_auth_enabled() {
+ let table = query_auth_table();
+ // Bypass `ReadBuilder` by constructing `TableRead` directly; the
`to_arrow` guard
+ // still fails closed.
+ let read = TableRead::new(&table, table.schema.fields().to_vec(),
Vec::new());
+ assert!(
+ matches!(
+ read.to_arrow(&[]),
+ Err(crate::Error::Unsupported { ref message }) if
message.contains("query-auth.enabled")
+ ),
+ "directly-constructed read of a query-auth.enabled table must fail
closed"
+ );
+ }
}
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index 57f21d8..93a1c35 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -634,6 +634,7 @@ impl<'a> TableScan<'a> {
///
/// Reference:
[TimeTravelUtil.tryTravelToSnapshot](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/TimeTravelUtil.java)
pub async fn plan(&self) -> crate::Result<Plan> {
+ self.ensure_query_auth_allowed()?;
let data_evolution_read_field_ids = self.projected_read_field_ids()?;
let snapshot = match self.resolve_snapshot().await? {
Some(snapshot) => snapshot,
@@ -645,6 +646,7 @@ impl<'a> TableScan<'a> {
/// Plan the full scan and return metadata-pruning trace counters.
pub async fn plan_with_trace(&self) -> crate::Result<(Plan, ScanTrace)> {
+ self.ensure_query_auth_allowed()?;
let data_evolution_read_field_ids = self.projected_read_field_ids()?;
let mut trace = ScanTrace {
limit: self.limit,
@@ -665,6 +667,13 @@ impl<'a> TableScan<'a> {
Ok((plan, trace))
}
+ /// Fail closed for a `query-auth.enabled` table: scan planning — including
+ /// `with_scan_all_files`, which read-facing system tables like `files`
use —
+ /// exposes file paths, row counts, and stats the client can't authorize.
+ fn ensure_query_auth_allowed(&self) -> crate::Result<()> {
+
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()
+ }
+
fn projected_read_field_ids(&self) -> crate::Result<Option<HashSet<i32>>> {
super::read_builder::projected_read_field_ids(
self.table.identifier().full_name(),
@@ -2565,4 +2574,38 @@ mod tests {
let bucket = *buckets.iter().next().unwrap();
assert!((0..8).contains(&bucket));
}
+
+ #[tokio::test]
+ async fn test_plan_fails_closed_when_query_auth_enabled() {
+ // Every scan-planning path must fail closed, including
`with_scan_all_files`
+ // (read-facing system tables like `files` use it to expose metadata).
+ let table = crate::table::query_auth_table();
+ let rb = table.new_read_builder();
+ for scan in [rb.new_scan(), rb.new_scan().with_scan_all_files()] {
+ let err = scan.plan().await.unwrap_err();
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message } if
message.contains("query-auth.enabled")),
+ "scan planning must fail closed (scan_all_files or not)"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn test_dynamic_option_cannot_disable_query_auth_at_plan() {
+ // Copying the table with the option off must not weaken a stored
`true`.
+ let table =
+
crate::table::query_auth_table().copy_with_options(std::collections::HashMap::from([
+ ("query-auth.enabled".to_string(), "false".to_string()),
+ ]));
+ let err = table
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message } if
message.contains("query-auth.enabled")),
+ "a dynamic override must not disable query-auth"
+ );
+ }
}
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index 2ca9993..fc0d2b7 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -105,6 +105,8 @@ impl<'a> VectorSearchBuilder<'a> {
}
pub async fn execute(&self) -> crate::Result<Vec<RowRange>> {
+ // Fail closed: returns data-derived row ranges outside
`TableScan`/`TableRead`.
+
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
let vector_column =
self.vector_column
.as_deref()
@@ -1282,6 +1284,20 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn test_execute_fails_closed_when_query_auth_enabled() {
+ let table = crate::table::query_auth_table();
+ let err = table
+ .new_vector_search_builder()
+ .execute()
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message } if
message.contains("query-auth.enabled")),
+ "vector search must fail closed for a query-auth table"
+ );
+ }
+
fn make_lumina_entry(
file_name: &str,
index_type: &str,