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 1cb01974 feat(auth): support query-auth BLOB column masking (#1089)
1cb01974 is described below
commit 1cb01974c1f905c9b66a3a3e4160d355abc95f2f
Author: Jiajia Li <[email protected]>
AuthorDate: Sat Oct 10 15:13:07 2026 +0800
feat(auth): support query-auth BLOB column masking (#1089)
---
crates/paimon/src/table/audit_log_table/read.rs | 13 +-
crates/paimon/src/table/query_auth.rs | 2 +-
crates/paimon/src/table/query_auth/rules.rs | 28 --
crates/paimon/src/table/table_read.rs | 59 ++-
crates/paimon/tests/rest_catalog_test.rs | 555 +++++++++++++++++++++++-
docs/src/sql.md | 3 +-
6 files changed, 608 insertions(+), 52 deletions(-)
diff --git a/crates/paimon/src/table/audit_log_table/read.rs
b/crates/paimon/src/table/audit_log_table/read.rs
index f63b9c00..6efce91a 100644
--- a/crates/paimon/src/table/audit_log_table/read.rs
+++ b/crates/paimon/src/table/audit_log_table/read.rs
@@ -58,8 +58,16 @@ impl<'a> AuditLogRead<'a> {
/// Reads splits planned by an audit scan, retaining winning retract rows.
pub fn to_arrow(&self, data_splits: &[DataSplit]) ->
crate::Result<ArrowRecordBatchStream> {
// The primary-key path below builds its readers directly, so decide
here.
- self.read
-
.ensure_authorized_by_splits(&self.read.table.schema.core_options(),
data_splits)?;
+ let restricted = self
+ .read
+
.ensure_authorized_by_splits(&self.read.table.schema.core_options(),
data_splits)?
+ .is_some();
+ let has_primary_keys =
!self.read.table.schema().primary_keys().is_empty();
+ if restricted && has_primary_keys {
+ return Err(crate::table::query_auth::unsupported(
+ "a primary-key audit log read cannot apply a row filter or
column masking",
+ ));
+ }
let output_read_type = self.read.read_type.clone();
if output_read_type
.iter()
@@ -72,7 +80,6 @@ impl<'a> AuditLogRead<'a> {
});
}
let audit_schema = build_target_arrow_schema(&output_read_type)?;
- let has_primary_keys =
!self.read.table.schema().primary_keys().is_empty();
let mut read_type: Vec<_> = output_read_type
.iter()
.filter(|field| field.id() != ROW_KIND_FIELD_ID)
diff --git a/crates/paimon/src/table/query_auth.rs
b/crates/paimon/src/table/query_auth.rs
index 06625d9d..3ae7d349 100644
--- a/crates/paimon/src/table/query_auth.rs
+++ b/crates/paimon/src/table/query_auth.rs
@@ -20,7 +20,7 @@
mod rules;
use crate::api::AuthTableQueryResponse;
-pub(crate) use rules::{filter_batch, mask_batch, mask_inputs, Rules};
+pub(crate) use rules::{filter_batch, mask_batch, mask_inputs, ColumnMask,
Rules};
/// 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.
diff --git a/crates/paimon/src/table/query_auth/rules.rs
b/crates/paimon/src/table/query_auth/rules.rs
index 66dc89f3..08650011 100644
--- a/crates/paimon/src/table/query_auth/rules.rs
+++ b/crates/paimon/src/table/query_auth/rules.rs
@@ -86,9 +86,6 @@ impl Rules {
"the server masks '{column}', which is not a column"
))
})?;
- if fields[target].data_type().is_blob_file_field() {
- return Err(unsupported("BLOB column masks are not supported
yet"));
- }
let transform = Transform::from_rest_json(json,
fields).map_err(|e| {
unsupported(&format!(
"cannot parse the server's mask on '{column}': {e}"
@@ -814,31 +811,6 @@ mod tests {
assert!(refusal(parse(&[&good, ""], &[], &fields)).contains("empty row
filter"));
}
- #[test]
- fn test_blob_masks_are_not_supported_yet() {
- use crate::spec::{ArrayType, BlobType, MapType};
-
- let blob = DataType::Blob(BlobType::new());
- for data_type in [
- blob.clone(),
- DataType::Array(ArrayType::new(blob.clone())),
- DataType::Map(MapType::new(string(), blob)),
- ] {
- let fields = vec![field(0, "payload", data_type.clone())];
- for mask in [
- NULL.to_string(),
- field_ref("payload"),
- cast("payload", &data_type.to_string()),
- ] {
- let message = refusal(parse(&[], &[("payload", &mask)],
&fields));
- assert!(
- message.contains("BLOB column masks are not supported
yet"),
- "{message}"
- );
- }
- }
- }
-
#[test]
fn test_parse_refuses_a_bad_mask() {
let fields = rule_fields();
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index 94c1ae8b..1b0e2587 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -1191,6 +1191,7 @@ impl<'a> PaimonTableRead<'a> {
row_filter_factory: None,
file_fields: schema_fields.clone(),
};
+ let blob_options = has_blobs.then(|| self.masked_blob_options(&masks));
let stream = stream.map(move |batch| {
let batch = batch?;
let names_match = batch.num_columns() == physical.len()
@@ -1208,6 +1209,7 @@ impl<'a> PaimonTableRead<'a> {
let batch = filter_batch(&batch, &filters, &schema_fields,
&physical)?;
// Safe ordinary conjuncts avoid masking rows the caller excludes.
let batch = filter_record_batch_by_predicates(batch, &predicates,
&physical)?;
+ // BLOB masks copy references; consumers resolve payloads lazily.
let batch = mask_batch(&batch, &masks, &schema_fields, &physical)?;
if has_blobs {
Ok(batch)
@@ -1216,8 +1218,8 @@ impl<'a> PaimonTableRead<'a> {
}
});
let stream: ArrowRecordBatchStream = Box::pin(stream);
- if has_blobs {
- return self.finish_authorized_blobs(stream, &batch_fields,
&pending, &core_options);
+ if let Some(options) = blob_options {
+ return self.finish_authorized_blobs(stream, &batch_fields,
&pending, options);
}
let stream = project_authorized_stream(stream, self.read_type.len());
Ok(match self.limit {
@@ -1226,15 +1228,66 @@ impl<'a> PaimonTableRead<'a> {
})
}
+ /// Copy the source's BLOB representation along with its references.
+ fn masked_blob_options(
+ &self,
+ masks: &[super::query_auth::ColumnMask],
+ ) -> std::collections::HashMap<String, String> {
+ use crate::spec::Transform;
+
+ let fields = self.table.schema().fields();
+ let copies: Vec<_> = masks
+ .iter()
+ .filter_map(|mask| {
+ let source = match &mask.transform {
+ Transform::FieldRef(index) | Transform::Cast(index, _) =>
&fields[*index],
+ _ => return None,
+ };
+ let target = &fields[mask.column];
+ (source.data_type().is_blob_file_field()
+ && target.data_type().is_blob_file_field()
+ && source.name() != target.name())
+ .then(|| (source.name(), target.name()))
+ })
+ .collect();
+ let mut options = self.table.schema().options().clone();
+ if copies.is_empty() {
+ return options;
+ }
+ let core_options = self.table.schema().core_options();
+ for (key, original) in [
+ (
+ "blob-descriptor-field",
+ core_options.blob_descriptor_fields(),
+ ),
+ ("blob-view-field", core_options.blob_view_fields()),
+ ("video-frame-field", core_options.video_frame_fields()),
+ ] {
+ let mut selected = original.clone();
+ for &(source, target) in &copies {
+ if original.contains(source) {
+ selected.insert(target.to_string());
+ } else {
+ selected.remove(target);
+ }
+ }
+ let mut selected: Vec<_> = selected.into_iter().collect();
+ selected.sort_unstable();
+ options.insert(key.to_string(), selected.join(","));
+ }
+ options
+ }
+
fn finish_authorized_blobs(
&self,
stream: ArrowRecordBatchStream,
batch_fields: &[DataField],
predicates: &[Predicate],
- core_options: &CoreOptions<'_>,
+ payload_options: std::collections::HashMap<String, String>,
) -> crate::Result<ArrowRecordBatchStream> {
use super::managed_blob_reader::{resolve_primary_key_blob_stream,
ManagedBlobReadPlan};
+ let core_options = &CoreOptions::new(&payload_options);
let view_fields = core_options.blob_view_fields();
let resolve_views = core_options.blob_view_resolve_enabled()
&& self.table.rest_env().is_some()
diff --git a/crates/paimon/tests/rest_catalog_test.rs
b/crates/paimon/tests/rest_catalog_test.rs
index c6882fb5..c95b91a4 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -1225,6 +1225,40 @@ async fn
test_blob_view_limit_only_resolves_selected_references() {
);
assert_eq!(ids(&read_all(&filtered).await.unwrap()), vec![1]);
+ // Masked views resolve once when their values are read.
+ for (mask, expected) in [
+ (serde_json::json!({"name": "NULL"}), vec![None, None, None]),
+ (
+ field_ref_mask(2, "picture", "BLOB"),
+ vec![Some(first_payload.clone()), None, Some(b"bob".to_vec())],
+ ),
+ ] {
+ server.set_auth_response(
+ "default",
+ "blob_view_target",
+ rules(&[int_leaf(0, "id", "LESS_THAN", 4)], &[("picture", mask)]),
+ );
+ let rows =
collect_blob_rows(&read_all(&authorized.new_read_builder()).await.unwrap());
+ assert_eq!(
+ rows.into_iter().map(|row| row.2).collect::<Vec<_>>(),
+ expected
+ );
+ let mut filtered = authorized.new_read_builder();
+ filtered.with_projection(&["id"]).unwrap().with_filter(
+ PredicateBuilder::new(authorized.schema().fields())
+ .equal("picture", Datum::Bytes(first_payload.clone()))
+ .unwrap(),
+ );
+ assert_eq!(
+ ids(&read_all(&filtered).await.unwrap()),
+ if expected[0].is_some() {
+ vec![1]
+ } else {
+ vec![]
+ },
+ );
+ }
+
// 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.
@@ -3045,36 +3079,505 @@ async fn
test_query_auth_filters_before_resolving_blob_payloads() {
.await
.unwrap();
assert_eq!(ids(&result), vec![3]);
+
+ // BLOB masks still return logical payloads.
+ g.set_rules(
+ &[int_leaf(0, "id", "NOT_EQUAL", 2)],
+ &[("payload", field_ref_mask(1, "payload", "BLOB"))],
+ );
+ let batches = read_all(&g.table.new_read_builder()).await.unwrap();
+ assert_eq!(ids(&batches), vec![1, 3]);
+ assert_eq!(
+ batches
+ .iter()
+ .flat_map(|batch| column::<LargeBinaryArray>(batch,
"payload").iter())
+ .flatten()
+ .collect::<Vec<_>>(),
+ vec![b"good".as_slice()],
+ );
+ assert_eq!(ids(&read_all(&filtered).await.unwrap()), vec![3]);
+
+ // A NULL mask must not open the missing payload.
+ g.set_rules(
+ &[int_leaf(0, "id", "GREATER_THAN", 1)],
+ &[("payload", serde_json::json!({"name": "NULL"}))],
+ );
+ let batches = read_all(&g.table.new_read_builder()).await.unwrap();
+ assert_eq!(ids(&batches), vec![2, 3]);
+ for batch in &batches {
+ assert_eq!(
+ column::<LargeBinaryArray>(batch, "payload").null_count(),
+ batch.num_rows(),
+ );
+ }
+ assert!(read_all(&filtered)
+ .await
+ .unwrap()
+ .iter()
+ .all(|b| b.num_rows() == 0));
+ let mut nulls = g.table.new_read_builder();
+ nulls.with_projection(&["id"]).unwrap().with_filter(
+ PredicateBuilder::new(g.table.schema().fields())
+ .is_null("payload")
+ .unwrap(),
+ );
+ assert_eq!(ids(&read_all(&nulls).await.unwrap()), vec![2, 3]);
+ nulls.with_limit(1);
+ assert_eq!(ids(&read_all(&nulls).await.unwrap()), vec![2]);
+ }
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_blob_masks_preserve_filter_and_limit_selection() {
+ let (_source, good, missing) = blob_payloads();
+ for primary_key in [true, false] {
+ let g = blob_mask_rows(primary_key, vec![None, Some(&missing),
Some(&good)]).await;
+ for masked in [false, true] {
+ let masks = if masked {
+ vec![
+ ("payload", field_ref_mask(1, "payload", "BLOB")),
+ ("label", upper(2, "label")),
+ ]
+ } else {
+ vec![]
+ };
+ g.set_rules(&[int_leaf(0, "id", "GREATER_THAN", 0)], &masks);
+ for batch_size in [1, 3] {
+ let table = g.table.copy_with_options(HashMap::from([(
+ "read.batch-size".to_string(),
+ batch_size.to_string(),
+ )]));
+ let predicates =
PredicateBuilder::new(table.schema().fields());
+ for (case, predicate, limit, expected) in [
+ (
+ "ordinary filter",
+ Some(predicates.equal("id", Datum::Int(3)).unwrap()),
+ None,
+ vec![3],
+ ),
+ (
+ "scalar mask filter",
+ Some(
+ predicates
+ .equal(
+ "label",
+ Datum::String(if masked { "ROW3" } else {
"row3" }.to_string()),
+ )
+ .unwrap(),
+ ),
+ None,
+ vec![3],
+ ),
+ ("limit", None, Some(1), vec![1]),
+ (
+ "remaining predicate",
+ Some(paimon::spec::Predicate::and(vec![
+ predicates.not_equal("id", Datum::Int(2)).unwrap(),
+ predicates
+ .equal("payload",
Datum::Bytes(b"good".to_vec()))
+ .unwrap(),
+ ])),
+ Some(1),
+ vec![3],
+ ),
+ (
+ "or predicate",
+ Some(paimon::spec::Predicate::or(vec![
+ predicates.equal("id", Datum::Int(3)).unwrap(),
+ predicates.is_null("payload").unwrap(),
+ ])),
+ Some(1),
+ vec![1],
+ ),
+ ("zero limit", None, Some(0), vec![]),
+ ] {
+ let mut read = table.new_read_builder();
+ if let Some(predicate) = predicate {
+ read.with_filter(predicate);
+ }
+ if let Some(limit) = limit {
+ read.with_limit(limit);
+ }
+ let label =
+ format!("pk={primary_key}, mask={masked},
batch={batch_size}, {case}");
+ let batches = read_all(&read)
+ .await
+ .unwrap_or_else(|error| panic!("{label}: {error}"));
+ assert_eq!(ids(&batches), expected, "{label}");
+ for batch in &batches {
+ 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), "{label}");
+ } else {
+ assert_eq!(payload.value(row), b"good",
"{label}");
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_blob_masks_keep_predicates_and_projection_lazy() {
+ let (_source, good, missing) = blob_payloads();
+ let field = serde_json::json!({"index": 1, "name": "payload", "type":
"BLOB"});
+ for primary_key in [true, false] {
+ let g = blob_mask_rows(primary_key, vec![Some(&missing), None,
Some(&good)]).await;
+ for (name, mask) in [
+ ("unmasked", None),
+ (
+ "field-ref",
+ Some(serde_json::json!({"name": "FIELD_REF", "fieldRef":
field})),
+ ),
+ (
+ "cast",
+ Some(serde_json::json!({"name": "CAST", "fieldRef": field,
"type": "BLOB"})),
+ ),
+ ] {
+ let masks: Vec<_> = mask.into_iter().map(|mask| ("payload",
mask)).collect();
+ g.set_rules(&[int_leaf(0, "id", "GREATER_THAN", 0)], &masks);
+ for batch_size in [1, 3] {
+ let table = g.table.copy_with_options(HashMap::from([(
+ "read.batch-size".to_string(),
+ batch_size.to_string(),
+ )]));
+ let predicates =
PredicateBuilder::new(table.schema().fields());
+ let first = predicates.equal("id", Datum::Int(1)).unwrap();
+ let payload = predicates
+ .equal("payload", Datum::Bytes(b"good".to_vec()))
+ .unwrap();
+ for (case, predicate, expected) in [
+ (
+ "or",
+ paimon::spec::Predicate::or(vec![first.clone(),
payload.clone()]),
+ vec![1, 3],
+ ),
+ (
+ "is-not-null",
+ predicates.is_not_null("payload").unwrap(),
+ vec![1, 3],
+ ),
+ ("is-null", predicates.is_null("payload").unwrap(),
vec![2]),
+ (
+ "nested-or",
+ paimon::spec::Predicate::or(vec![
+ paimon::spec::Predicate::and(vec![
+ first.clone(),
+ predicates.is_not_null("payload").unwrap(),
+ ]),
+ payload.clone(),
+ ]),
+ vec![1, 3],
+ ),
+ ] {
+ for limit in [None, Some(1)] {
+ let mut read = table.new_read_builder();
+ read.with_projection(&["id"])
+ .unwrap()
+ .with_filter(predicate.clone());
+ if let Some(limit) = limit {
+ read.with_limit(limit);
+ }
+ let label = format!(
+ "pk={primary_key}, {name}, batch={batch_size},
{case}, limit={limit:?}"
+ );
+ let batches = read_all(&read)
+ .await
+ .unwrap_or_else(|error| panic!("{label}:
{error}"));
+ assert_eq!(
+ ids(&batches),
+ expected[..limit.unwrap_or(expected.len())],
+ "{label}"
+ );
+ }
+ }
+ for (projection, predicate) in [(vec!["payload"], first),
(vec!["id"], payload)] {
+ let mut read = table.new_read_builder();
+ read.with_projection(&projection)
+ .unwrap()
+ .with_filter(predicate);
+ let error = read_all(&read).await.unwrap_err().to_string();
+ assert!(error.contains("missing.blob"), "{error}");
+ }
+ let mut read = table.new_read_builder();
+ read.with_projection(&["payload"])
+ .unwrap()
+ .with_filter(predicates.equal("id",
Datum::Int(3)).unwrap());
+ let batches = read_all(&read).await.unwrap();
+
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+ let batch = batches.iter().find(|batch| batch.num_rows() >
0).unwrap();
+ assert_eq!(
+ column::<LargeBinaryArray>(batch, "payload").value(0),
+ b"good"
+ );
+ }
+ }
}
}
#[cfg(not(windows))]
#[tokio::test]
-async fn test_query_auth_refuses_blob_masks() {
+async fn test_blob_mask_alias_keeps_the_source_reference_format() {
+ let (_source, good, missing) = blob_payloads();
for primary_key in [true, false] {
- let g = blob_mask_rows(primary_key, vec![None]).await;
+ 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![
+ Some(missing.as_slice()),
+ None,
+ Some(good.as_slice()),
+ ])) as arrow_array::ArrayRef,
+ ),
+ (
+ "alias",
+ Arc::new(LargeBinaryArray::from(vec![None::<&[u8]>; 3])) as
arrow_array::ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let g = written(
+ "auth_blob_alias",
+ |options| {
+ let mut schema = Schema::builder()
+ .column("id", DataType::Int(IntType::with_nullable(false)))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .column("alias", DataType::Blob(BlobType::new()))
+ .option("blob-descriptor-field", "payload")
+ .option("blob-view-field", "alias");
+ if primary_key {
+ schema = schema.primary_key(["id"]).option("bucket", "1");
+ } else {
+ schema = schema
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true");
+ }
+ for (key, value) in options {
+ schema = schema.option(*key, *value);
+ }
+ schema.build().unwrap()
+ },
+ vec![batch],
+ )
+ .await;
+ let field = serde_json::json!({"index": 1, "name": "payload", "type":
"BLOB"});
for mask in [
- serde_json::json!({"name": "NULL"}),
- field_ref_mask(1, "payload", "BLOB"),
- serde_json::json!({"name": "CAST", "fieldRef": {
- "index": 1, "name": "payload", "type": "BLOB",
- }, "type": "BLOB"}),
+ serde_json::json!({"name": "FIELD_REF", "fieldRef": field}),
+ serde_json::json!({"name": "CAST", "fieldRef": field, "type":
"BLOB"}),
] {
- g.set_rules(&[], &[("payload", mask)]);
- for projection in [vec!["payload"], vec!["id"], vec![]] {
+ g.set_rules(&[int_leaf(0, "id", "GREATER_THAN", 0)], &[("alias",
mask)]);
+ let predicates = PredicateBuilder::new(g.table.schema().fields());
+ for (predicate, expected) in [
+ (
+ paimon::spec::Predicate::or(vec![
+ predicates.equal("id", Datum::Int(1)).unwrap(),
+ predicates
+ .equal("alias", Datum::Bytes(b"good".to_vec()))
+ .unwrap(),
+ ]),
+ vec![1, 3],
+ ),
+ (predicates.is_not_null("alias").unwrap(), vec![1, 3]),
+ (predicates.is_null("alias").unwrap(), vec![2]),
+ ] {
let mut read = g.table.new_read_builder();
- read.with_projection(&projection).unwrap();
- let error = read.new_scan().plan().await.unwrap_err();
- assert!(
- matches!(error, paimon::Error::Unsupported { ref message }
- if message.contains("BLOB column masks are not supported
yet")),
- "{error:?}"
- );
+ read.with_projection(&["id"])
+ .unwrap()
+ .with_filter(predicate);
+ assert_eq!(ids(&read_all(&read).await.unwrap()), expected);
}
+ let mut read = g.table.new_read_builder();
+ read.with_projection(&["alias"])
+ .unwrap()
+ .with_filter(predicates.equal("id", Datum::Int(3)).unwrap());
+ let batches = read_all(&read).await.unwrap();
+ let batch = batches.iter().find(|batch| batch.num_rows() >
0).unwrap();
+ assert_eq!(column::<LargeBinaryArray>(batch, "alias").value(0),
b"good");
+ read.with_filter(predicates.equal("id", Datum::Int(1)).unwrap());
+ let error = read_all(&read).await.unwrap_err().to_string();
+ assert!(error.contains("missing.blob"), "{error}");
}
}
}
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_blob_view_mask_alias_resolves_the_source_view() {
+ let tmp = tempfile::tempdir().unwrap();
+ let warehouse = format!("file://{}", tmp.path().display());
+ let mut fs_options = Options::new();
+ fs_options.set(CatalogOptions::WAREHOUSE, &warehouse);
+ let fs_catalog = FileSystemCatalog::new(fs_options).unwrap();
+ fs_catalog
+ .create_database("default", true, HashMap::new())
+ .await
+ .unwrap();
+
+ let source_id = Identifier::new("default", "alias_view_source");
+ let source_schema = blob_schema(&[]);
+ fs_catalog
+ .create_table(&source_id, source_schema.clone(), false)
+ .await
+ .unwrap();
+ let source = fs_catalog.get_table(&source_id).await.unwrap();
+ write_batch(
+ &source,
+ blob_batch(
+ vec![1, 2],
+ vec!["Alice", "Bob"],
+ vec![b"alice".to_vec(), b"bob".to_vec()],
+ ),
+ "source-writer",
+ )
+ .await;
+ let picture_field_id = source
+ .schema()
+ .fields()
+ .iter()
+ .find(|field| field.name() == "picture")
+ .unwrap()
+ .id();
+ let view_ref = |row_id| {
+ BlobViewStruct::new(source_id.clone(), picture_field_id, row_id)
+ .serialize()
+ .unwrap()
+ };
+
+ // Only `picture` is a view; the mask makes `alias` one by copying it.
+ let view_id = Identifier::new("default", "alias_view_target");
+ let view_schema = |options: &[(&str, &str)]| {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("picture", DataType::Blob(BlobType::new()))
+ .column("alias", DataType::Blob(BlobType::new()))
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true")
+ .option("blob-view-field", "picture");
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+ };
+ fs_catalog
+ .create_table(&view_id, view_schema(&[]), false)
+ .await
+ .unwrap();
+ let view = fs_catalog.get_table(&view_id).await.unwrap();
+ let (first, second) = (view_ref(0), view_ref(1));
+ let batch = RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![1, 2, 3])) as arrow_array::ArrayRef,
+ ),
+ (
+ "picture",
+ Arc::new(LargeBinaryArray::from(vec![
+ Some(first.as_slice()),
+ None,
+ Some(second.as_slice()),
+ ])) as arrow_array::ArrayRef,
+ ),
+ (
+ "alias",
+ Arc::new(LargeBinaryArray::from(vec![None::<&[u8]>; 3])) as
arrow_array::ArrayRef,
+ ),
+ ])
+ .unwrap();
+ write_batch(&view, batch, "view-writer").await;
+
+ let mut defaults = HashMap::new();
+ defaults.insert("prefix".to_string(), "mock-test".to_string());
+ let server = start_mock_server(
+ "test_warehouse".to_string(),
+ warehouse.clone(),
+ ConfigResponse::new(defaults),
+ vec!["default".to_string()],
+ )
+ .await;
+ server.add_table_with_schema(
+ "default",
+ "alias_view_source",
+ source_schema,
+ source.location(),
+ );
+ server.add_table_with_schema(
+ "default",
+ "alias_view_target",
+ view_schema(&[("query-auth.enabled", "true")]),
+ view.location(),
+ );
+ let read_via =
paimon::api::RESTUtil::encode_string(&serde_json::to_string(&view_id).unwrap());
+ server.require_table_read_via("default", "alias_view_source", read_via);
+ server.set_auth_response(
+ "default",
+ "alias_view_target",
+ rules(&[], &[("alias", field_ref_mask(1, "picture", "BLOB"))]),
+ );
+ let mut rest_options = Options::new();
+ rest_options.set("uri", server.url().unwrap());
+ rest_options.set("warehouse", "test_warehouse");
+ rest_options.set("token.provider", "bear");
+ rest_options.set("token", "test_token");
+ let rest_catalog = RESTCatalog::new(rest_options, true).await.unwrap();
+ let table = rest_catalog.get_table(&view_id).await.unwrap();
+
+ let payloads = |batches: &[RecordBatch], name: &str| {
+ let mut rows: Vec<(i32, Option<Vec<u8>>)> = batches
+ .iter()
+ .flat_map(|b| {
+ let ids = column::<Int32Array>(b, "id").values().to_vec();
+ let values = column::<LargeBinaryArray>(b, name).iter();
+ ids.into_iter().zip(values.map(|v| v.map(|v| v.to_vec())))
+ })
+ .collect();
+ rows.sort();
+ rows
+ };
+ let expected = vec![
+ (1, Some(b"alice".to_vec())),
+ (2, None),
+ (3, Some(b"bob".to_vec())),
+ ];
+ // The alias resolves to the source view's payload, alone or beside it.
+ for projection in [vec!["id", "alias"], vec!["id", "picture", "alias"]] {
+ let mut read = table.new_read_builder();
+ read.with_projection(&projection).unwrap();
+ let batches = read_all(&read).await.unwrap();
+ assert_eq!(payloads(&batches, "alias"), expected);
+ if projection.contains(&"picture") {
+ assert_eq!(payloads(&batches, "picture"), expected);
+ }
+ }
+
+ // Predicates on the alias match the resolved payload, not the reference.
+ let predicates = PredicateBuilder::new(table.schema().fields());
+ for (predicate, expected) in [
+ (
+ predicates
+ .equal("alias", Datum::Bytes(b"bob".to_vec()))
+ .unwrap(),
+ vec![3],
+ ),
+ (predicates.is_null("alias").unwrap(), vec![2]),
+ (predicates.is_not_null("alias").unwrap(), vec![1, 3]),
+ ] {
+ let mut read = table.new_read_builder();
+ read.with_projection(&["id"])
+ .unwrap()
+ .with_filter(predicate);
+ assert_eq!(ids(&read_all(&read).await.unwrap()), expected);
+ }
+}
+
fn schema_of(columns: &[&str], options: &[(&str, &str)]) -> Schema {
let mut builder = Schema::builder();
for name in columns {
@@ -4623,6 +5126,26 @@ async fn
test_query_auth_masks_the_merged_primary_key_row() {
);
}
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_refuses_a_restricted_primary_key_audit_log_read() {
+ let g = scores().await;
+ g.set_rules(
+ &[int_leaf(2, "score", "GREATER_THAN", 30)],
+ &[("name", upper(1, "name"))],
+ );
+ // An audit scan is refused at planning, but ordinary splits still carry
+ // the grant to a directly built audit read.
+ let builder = g.table.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let audit =
paimon::table::AuditLogRead::new(builder.new_read().unwrap()).unwrap();
+ // It builds its primary-key readers itself, so it must not return raw
rows.
+ let Err(err) = audit.to_arrow(plan.splits()) else {
+ panic!("a restricted primary-key audit read must be refused")
+ };
+ assert_refused(err);
+}
+
#[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 b9a2e64f..6de93f0e 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1650,7 +1650,8 @@ query asks the catalog what the current user may read,
and the row filters and
column masks it returns (see `create_policy`) are applied to the result:
- Rows are filtered on their stored values; masked columns return masked
values.
-- BLOB column masks are not yet supported.
+- BLOB masks keep payload reads lazy. A NULL mask does not read the original
+ BLOB payload.
- Query filters on masked columns, including partition keys, match masked
values.
- Statistics are not used under a row filter or a mask, and a row filter turns
off `LIMIT` pushdown, so such queries read the data.