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 5de0ba34 feat(datafusion): add REST management procedures (#837)
5de0ba34 is described below

commit 5de0ba34df50f4806430e4a19cd27dfa9ee569a7
Author: Jiajia Li <[email protected]>
AuthorDate: Tue Sep 22 14:16:51 2026 +0800

    feat(datafusion): add REST management procedures (#837)
---
 .github/workflows/ci.yml                           |   2 +
 crates/integrations/datafusion/src/procedures.rs   | 539 +++++++++++++-
 .../datafusion/tests/rest_management_procedures.rs | 817 +++++++++++++++++++++
 crates/paimon/src/catalog/filesystem.rs            |   6 +
 crates/paimon/src/catalog/mod.rs                   |   7 +
 crates/paimon/src/catalog/rest/rest_catalog.rs     |   3 +
 docs/src/sql.md                                    | 117 +++
 7 files changed, 1488 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5f2336a3..1b84a707 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -267,6 +267,8 @@ jobs:
           --features fulltext,vortex
           -- --ignored --exact
 
+      # The test filter selects tests, not targets, so without `--test` cargo 
links all 26
+      # integration binaries with `vortex` enabled just to run one ignored 
test in one of them.
       - name: DataFusion Lumina Build Query E2E Test
         if: matrix.suite == 'lumina'
         run: >
diff --git a/crates/integrations/datafusion/src/procedures.rs 
b/crates/integrations/datafusion/src/procedures.rs
index 04941451..3ea1f6c6 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -29,13 +29,27 @@
 //! - `CALL sys.drop_global_index(table => '...', index_column => '...', 
index_type => 'btree')` (also 'bitmap', 'multivalue', 'fm', 'lumina', or a 
vindex type such as 'ivf-pq')
 //! - `CALL sys.create_lumina_index(table => '...', index_column => '...')`
 //!
+//! REST management procedures (REST catalogs only, mirroring Java's
+//! `RESTCatalog.permissionManagement()` / `policyManagement()`):
+//! - `CALL sys.grant_permission(resource_type => '...', access => '...', 
principal => '...'[, database, table, function, view, expire_time, 
column_names, excluded_column_names])`
+//! - `CALL sys.revoke_permission(resource_type => '...', access => '...', 
principal => '...'[, database, table, function, view])`
+//! - `CALL sys.list_permissions(resource_type => '...'[, database, table, 
function, view, principal, access, max_results, page_token])`
+//! - `CALL sys.create_policy(database => '...', table => '...', policy_type 
=> '...', principal => '...'[, predicate_json, on_column, transform_json])`
+//! - `CALL sys.drop_policy(database => '...', table => '...', policy_type => 
'...', principal => '...'[, column, if_exists])`
+//! - `CALL sys.list_policies(database => '...', table => '...'[, policy_type, 
principal, column, max_results, page_token])`
+//!
 //! The `index_type` argument of the three global index procedures is
 //! case-insensitive and surrounding whitespace is ignored.
+//!
+//! `column_names` and `excluded_column_names` are comma-separated lists, not 
SQL arrays:
+//! `column_names => 'id, region'`. Java's Spark procedures take 
`ARRAY<STRING>` there, but a
+//! DataFusion CALL argument is always a scalar, so this crate uses the same 
comma convention as
+//! `delete_tag`'s `tag` argument.
 
 use std::collections::HashMap;
 use std::sync::Arc;
 
-use datafusion::arrow::array::StringArray;
+use datafusion::arrow::array::{ArrayRef, StringArray};
 use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Schema};
 use datafusion::arrow::record_batch::RecordBatch;
 use datafusion::error::{DataFusionError, Result as DFResult};
@@ -44,7 +58,11 @@ use datafusion::sql::sqlparser::ast::{
     Expr as SqlExpr, Function, FunctionArg, FunctionArgExpr, 
FunctionArgOperator,
     FunctionArguments, ObjectName, Value as SqlValue,
 };
-use paimon::catalog::{Catalog, Identifier};
+use paimon::api::{
+    ColumnMask, DataPolicy, ListPermissionsRequest, ListPoliciesRequest, 
PermissionAssignment,
+    PermissionColumns, PermissionResource, PolicyType, ResourceType, RowFilter,
+};
+use paimon::catalog::{Catalog, Identifier, RESTCatalog};
 use paimon::lumina::LUMINA_IDENTIFIER;
 use paimon::spec::Snapshot;
 use paimon::table::{
@@ -141,6 +159,71 @@ async fn earlier_or_equal_from_all(
     }
 }
 
+/// The parameter names Java declares for each management procedure, in Java's 
order.
+fn management_parameters(proc_name: &str) -> Option<&'static [&'static str]> {
+    Some(match proc_name {
+        "grant_permission" => &[
+            "resource_type",
+            "access",
+            "principal",
+            "database",
+            "table",
+            "function",
+            "view",
+            "expire_time",
+            "column_names",
+            "excluded_column_names",
+        ],
+        "revoke_permission" => &[
+            "resource_type",
+            "access",
+            "principal",
+            "database",
+            "table",
+            "function",
+            "view",
+        ],
+        "list_permissions" => &[
+            "resource_type",
+            "database",
+            "table",
+            "function",
+            "view",
+            "principal",
+            "access",
+            "max_results",
+            "page_token",
+        ],
+        "create_policy" => &[
+            "database",
+            "table",
+            "policy_type",
+            "principal",
+            "predicate_json",
+            "on_column",
+            "transform_json",
+        ],
+        "drop_policy" => &[
+            "database",
+            "table",
+            "policy_type",
+            "principal",
+            "column",
+            "if_exists",
+        ],
+        "list_policies" => &[
+            "database",
+            "table",
+            "policy_type",
+            "principal",
+            "column",
+            "max_results",
+            "page_token",
+        ],
+        _ => return None,
+    })
+}
+
 pub async fn execute_call(
     ctx: &SessionContext,
     catalogs: &HashMap<String, Arc<dyn Catalog>>,
@@ -154,6 +237,17 @@ pub async fn execute_call(
         .ok_or_else(|| DataFusionError::Plan(format!("Unknown catalog 
'{catalog_name}'")))?;
     let args = extract_named_args(&func.args)?;
 
+    // Java rejects an argument name no parameter declares, so a typo cannot 
be dropped in
+    // silence. `expiretime => ...` on a grant would otherwise send a 
permanent one.
+    if let Some(declared) = management_parameters(&proc_name) {
+        if let Some(unknown) = args.keys().find(|key| 
!declared.contains(&key.as_str())) {
+            return Err(DataFusionError::Plan(format!(
+                "Argument {unknown} is unknown. Expected one of [{}].",
+                declared.join(", ")
+            )));
+        }
+    }
+
     match proc_name.as_str() {
         "create_tag" => proc_create_tag(ctx, catalog, catalog_name, 
&args).await,
         "delete_tag" => proc_delete_tag(ctx, catalog, catalog_name, 
&args).await,
@@ -167,6 +261,12 @@ pub async fn execute_call(
         "create_global_index" => proc_create_global_index(ctx, catalog, 
catalog_name, &args).await,
         "drop_global_index" => proc_drop_global_index(ctx, catalog, 
catalog_name, &args).await,
         "create_lumina_index" => proc_create_lumina_index(ctx, catalog, 
catalog_name, &args).await,
+        "grant_permission" => proc_grant_permission(ctx, catalog, 
catalog_name, &args).await,
+        "revoke_permission" => proc_revoke_permission(ctx, catalog, 
catalog_name, &args).await,
+        "list_permissions" => proc_list_permissions(ctx, catalog, 
catalog_name, &args).await,
+        "create_policy" => proc_create_policy(ctx, catalog, catalog_name, 
&args).await,
+        "drop_policy" => proc_drop_policy(ctx, catalog, catalog_name, 
&args).await,
+        "list_policies" => proc_list_policies(ctx, catalog, catalog_name, 
&args).await,
         _ => Err(DataFusionError::Plan(format!(
             "Unknown procedure: {proc_name}"
         ))),
@@ -210,7 +310,14 @@ fn extract_named_args(args: &FunctionArguments) -> 
DFResult<HashMap<String, Stri
                 operator: FunctionArgOperator::RightArrow,
             } => {
                 let value = expr_to_string(expr)?;
-                map.insert(name.value.to_lowercase(), value);
+                let name = name.value.to_lowercase();
+                // Java `PaimonProcedureResolver.buildNameToArgumentMap` 
rejects a repeat
+                // rather than letting the last one win.
+                if map.insert(name.clone(), value).is_some() {
+                    return Err(DataFusionError::Plan(format!(
+                        "Procedure argument {name} is duplicated."
+                    )));
+                }
             }
             _ => return Err(DataFusionError::Plan(
                 "CALL procedures require named arguments with '=>' syntax, 
e.g. table => 'db.t'"
@@ -656,6 +763,432 @@ fn parse_key_value_options(options: &str) -> 
DFResult<HashMap<String, String>> {
     Ok(parsed)
 }
 
+// ==================== REST management procedures (Java #9410) 
====================
+//
+// Parameter names follow Java's; divergences are called out where they occur.
+
+/// The REST catalog behind `catalog`, which is where permission and policy 
management lives.
+/// Java does the same check with `DelegateCatalog.rootCatalog(...) instanceof 
RESTCatalog`.
+fn rest_catalog<'a>(
+    catalog: &'a Arc<dyn Catalog>,
+    catalog_name: &str,
+) -> DFResult<&'a RESTCatalog> {
+    catalog
+        .as_any()
+        .and_then(|any| any.downcast_ref::<RESTCatalog>())
+        .ok_or_else(|| {
+            DataFusionError::Plan(format!(
+                "Catalog '{catalog_name}' does not support permission or 
policy management."
+            ))
+        })
+}
+
+/// Java's blankness, which is `String.trim()`: only `<= U+0020` counts. 
`str::trim` would also
+/// strip the rest of Unicode whitespace and disagree on a non-breaking space. 
(`listagg.rs` has
+/// a third variant using `Character.isWhitespace`; none of the three are 
interchangeable.)
+fn is_blank(value: &str) -> bool {
+    value.chars().all(|ch| ch <= ' ')
+}
+
+/// Java `emptyToNull`: a blank value is absent.
+fn opt_arg<'a>(args: &'a HashMap<String, String>, name: &str) -> Option<&'a 
str> {
+    args.get(name)
+        .map(String::as_str)
+        .filter(|value| !is_blank(value))
+}
+
+/// Java `BasePermissionProcedure.enumValue`.
+/// The values Java prints in `Invalid <arg> '<value>'. Expected one of 
<values>.`
+trait ProcedureEnum: std::str::FromStr<Err = paimon::Error> + Sized {
+    fn allowed() -> Vec<&'static str>;
+}
+
+impl ProcedureEnum for ResourceType {
+    fn allowed() -> Vec<&'static str> {
+        ResourceType::VALUES
+            .iter()
+            .map(ResourceType::as_str)
+            .collect()
+    }
+}
+
+impl ProcedureEnum for PolicyType {
+    fn allowed() -> Vec<&'static str> {
+        vec![
+            PolicyType::RowFilter.as_str(),
+            PolicyType::ColumnMasking.as_str(),
+        ]
+    }
+}
+
+fn enum_arg<T>(args: &HashMap<String, String>, name: &str) -> DFResult<T>
+where
+    T: ProcedureEnum,
+{
+    let value = require_arg(args, name)?;
+    // Java neither trims nor accepts blank, so ' TABLE ' must stay an error 
here too.
+    if is_blank(value) {
+        return Err(DataFusionError::Plan(format!("{name} cannot be empty.")));
+    }
+    value.parse().map_err(|_| {
+        DataFusionError::Plan(format!(
+            "Invalid {name} '{value}'. Expected one of [{}].",
+            T::allowed().join(", ")
+        ))
+    })
+}
+
+/// Same as [`enum_arg`], but the argument may be absent or blank.
+fn opt_enum_arg<T>(args: &HashMap<String, String>, name: &str) -> 
DFResult<Option<T>>
+where
+    T: ProcedureEnum,
+{
+    match opt_arg(args, name) {
+        None => Ok(None),
+        Some(_) => enum_arg(args, name).map(Some),
+    }
+}
+
+/// Comma-separated, like `delete_tag`'s `tag`. Java declares `ARRAY<STRING>`, 
but a
+/// DataFusion CALL argument is always a scalar.
+fn comma_list(args: &HashMap<String, String>, name: &str) -> 
Option<Vec<String>> {
+    args.get(name).map(|raw| {
+        raw.split(',')
+            // Java's trim, as everywhere else here: a non-breaking space is 
part of the name.
+            .map(|value| value.trim_matches(|ch| ch <= ' '))
+            .filter(|value| !value.is_empty())
+            .map(str::to_string)
+            .collect()
+    })
+}
+
+fn bool_arg(args: &HashMap<String, String>, name: &str) -> DFResult<bool> {
+    // Java's `ProcedureParameter.optional(..., BooleanType)` leaves a missing 
value as false.
+    match args.get(name) {
+        None => Ok(false),
+        Some(value) => match value.trim().to_ascii_lowercase().as_str() {
+            "true" => Ok(true),
+            "false" => Ok(false),
+            _ => Err(DataFusionError::Plan(format!(
+                "Invalid {name} '{value}'. Expected 'true' or 'false'"
+            ))),
+        },
+    }
+}
+
+fn max_results_arg(args: &HashMap<String, String>) -> DFResult<Option<u32>> {
+    args.get("max_results")
+        .map(|value| {
+            value
+                .trim()
+                .parse()
+                .map_err(|_| DataFusionError::Plan(format!("Invalid 
max_results: '{value}'")))
+        })
+        .transpose()
+}
+
+/// `resource_type` plus whichever locators it needs (Java 
`BasePermissionProcedure.resource`).
+fn permission_resource(args: &HashMap<String, String>) -> 
DFResult<PermissionResource> {
+    PermissionResource::new(
+        enum_arg::<ResourceType>(args, "resource_type")?,
+        opt_arg(args, "database"),
+        opt_arg(args, "table"),
+        opt_arg(args, "function"),
+        opt_arg(args, "view"),
+    )
+    .map_err(to_datafusion_error)
+}
+
+/// The `TABLE` resource a policy hangs off (Java 
`BasePolicyProcedure.tableResource`).
+fn policy_table_resource(args: &HashMap<String, String>) -> 
DFResult<PermissionResource> {
+    PermissionResource::new(
+        ResourceType::Table,
+        Some(require_arg(args, "database")?),
+        Some(require_arg(args, "table")?),
+        None,
+        None,
+    )
+    .map_err(to_datafusion_error)
+}
+
+/// Java hands both lists to `PermissionColumns` and lets it reject having 
both; each Rust
+/// constructor takes one list, so that case is rejected here instead.
+fn permission_columns(args: &HashMap<String, String>) -> 
DFResult<Option<PermissionColumns>> {
+    match (
+        comma_list(args, "column_names"),
+        comma_list(args, "excluded_column_names"),
+    ) {
+        (None, None) => Ok(None),
+        (Some(names), None) => PermissionColumns::names(names)
+            .map(Some)
+            .map_err(to_datafusion_error),
+        (None, Some(excluded)) => PermissionColumns::excluded(excluded)
+            .map(Some)
+            .map_err(to_datafusion_error),
+        (Some(_), Some(_)) => Err(DataFusionError::Plan(
+            "columns must contain exactly one of column_names or 
excluded_column_names."
+                .to_string(),
+        )),
+    }
+}
+
+fn utf8_result(
+    ctx: &SessionContext,
+    fields: &[(&str, bool)],
+    rows: Vec<Vec<Option<String>>>,
+) -> DFResult<DataFrame> {
+    debug_assert!(
+        rows.iter().all(|row| row.len() == fields.len()),
+        "every row must have one cell per declared column"
+    );
+    let schema = Arc::new(Schema::new(
+        fields
+            .iter()
+            .map(|(name, nullable)| Field::new(*name, ArrowDataType::Utf8, 
*nullable))
+            .collect::<Vec<_>>(),
+    ));
+    let columns = (0..fields.len())
+        .map(|column| {
+            Arc::new(
+                rows.iter()
+                    .map(|row| row[column].clone())
+                    .collect::<StringArray>(),
+            ) as ArrayRef
+        })
+        .collect::<Vec<_>>();
+    ctx.read_batch(RecordBatch::try_new(schema, columns)?)
+}
+
+fn joined(values: Option<&[String]>) -> Option<String> {
+    values.map(|values| values.join(","))
+}
+
+async fn proc_grant_permission(
+    ctx: &SessionContext,
+    catalog: &Arc<dyn Catalog>,
+    catalog_name: &str,
+    args: &HashMap<String, String>,
+) -> DFResult<DataFrame> {
+    let rest = rest_catalog(catalog, catalog_name)?;
+    let assignment = PermissionAssignment::new(
+        permission_resource(args)?,
+        require_arg(args, "access")?,
+        require_arg(args, "principal")?,
+        permission_columns(args)?,
+        opt_arg(args, "expire_time"),
+    )
+    .map_err(to_datafusion_error)?;
+    rest.grant_permission(&assignment)
+        .await
+        .map_err(to_datafusion_error)?;
+    // Java returns boolean `true`; every write procedure here answers 
`ok_result` instead.
+    ok_result(ctx)
+}
+
+async fn proc_revoke_permission(
+    ctx: &SessionContext,
+    catalog: &Arc<dyn Catalog>,
+    catalog_name: &str,
+    args: &HashMap<String, String>,
+) -> DFResult<DataFrame> {
+    let rest = rest_catalog(catalog, catalog_name)?;
+    rest.revoke_permission(
+        &permission_resource(args)?,
+        require_arg(args, "access")?,
+        require_arg(args, "principal")?,
+    )
+    .await
+    .map_err(to_datafusion_error)?;
+    ok_result(ctx)
+}
+
+async fn proc_list_permissions(
+    ctx: &SessionContext,
+    catalog: &Arc<dyn Catalog>,
+    catalog_name: &str,
+    args: &HashMap<String, String>,
+) -> DFResult<DataFrame> {
+    let rest = rest_catalog(catalog, catalog_name)?;
+    let request = ListPermissionsRequest {
+        resource: permission_resource(args)?,
+        principal: opt_arg(args, "principal").map(str::to_string),
+        access: opt_arg(args, "access").map(str::to_string),
+        max_results: max_results_arg(args)?,
+        page_token: opt_arg(args, "page_token").map(str::to_string),
+    };
+    let page = rest
+        .list_permissions_paged(&request)
+        .await
+        .map_err(to_datafusion_error)?;
+
+    // Java parity, quirk included: `next_page_token` repeats on every row, so 
an empty page
+    // returns zero rows and loses it. See `ListPermissionsProcedure.call`.
+    let rows = page
+        .elements
+        .iter()
+        .map(|assignment| {
+            let resource = assignment.resource();
+            let columns = assignment.columns();
+            vec![
+                Some(resource.resource_type().to_string()),
+                resource.database_name().map(str::to_string),
+                resource.table_name().map(str::to_string),
+                resource.function_name().map(str::to_string),
+                resource.view_name().map(str::to_string),
+                Some(assignment.access().to_string()),
+                Some(assignment.principal().to_string()),
+                // Comma-joined, matching how `column_names` is passed in.
+                joined(columns.and_then(PermissionColumns::column_names)),
+                
joined(columns.and_then(PermissionColumns::excluded_column_names)),
+                assignment.expire_time().map(str::to_string),
+                page.next_page_token.clone(),
+            ]
+        })
+        .collect();
+
+    utf8_result(
+        ctx,
+        &[
+            ("resource_type", false),
+            ("database", true),
+            ("table", true),
+            ("function", true),
+            ("view", true),
+            ("access", false),
+            ("principal", false),
+            ("column_names", true),
+            ("excluded_column_names", true),
+            ("expire_time", true),
+            ("next_page_token", true),
+        ],
+        rows,
+    )
+}
+
+async fn proc_create_policy(
+    ctx: &SessionContext,
+    catalog: &Arc<dyn Catalog>,
+    catalog_name: &str,
+    args: &HashMap<String, String>,
+) -> DFResult<DataFrame> {
+    let rest = rest_catalog(catalog, catalog_name)?;
+    let resource = policy_table_resource(args)?;
+    let principal = require_arg(args, "principal")?;
+    let predicate = opt_arg(args, "predicate_json");
+    let on_column = opt_arg(args, "on_column");
+    let transform = opt_arg(args, "transform_json");
+
+    // Java `BasePolicyProcedure.policy`: each policy type rejects the other's 
fields.
+    let policy = match enum_arg::<PolicyType>(args, "policy_type")? {
+        PolicyType::RowFilter => {
+            for (value, name) in [(on_column, "on_column"), (transform, 
"transform_json")] {
+                if value.is_some() {
+                    return Err(DataFusionError::Plan(format!(
+                        "ROW_FILTER policy cannot specify {name}."
+                    )));
+                }
+            }
+            let row_filter =
+                
RowFilter::new(predicate.unwrap_or_default()).map_err(to_datafusion_error)?;
+            DataPolicy::new_row_filter(resource, row_filter, principal)
+        }
+        PolicyType::ColumnMasking => {
+            if predicate.is_some() {
+                return Err(DataFusionError::Plan(
+                    "COLUMN_MASKING policy cannot specify 
predicate_json.".to_string(),
+                ));
+            }
+            let column_mask =
+                ColumnMask::new(on_column.unwrap_or_default(), 
transform.unwrap_or_default())
+                    .map_err(to_datafusion_error)?;
+            DataPolicy::new_column_mask(resource, column_mask, principal)
+        }
+    }
+    .map_err(to_datafusion_error)?;
+
+    rest.create_policy(&policy)
+        .await
+        .map_err(to_datafusion_error)?;
+    ok_result(ctx)
+}
+
+async fn proc_drop_policy(
+    ctx: &SessionContext,
+    catalog: &Arc<dyn Catalog>,
+    catalog_name: &str,
+    args: &HashMap<String, String>,
+) -> DFResult<DataFrame> {
+    let rest = rest_catalog(catalog, catalog_name)?;
+    rest.drop_policy(
+        &policy_table_resource(args)?,
+        enum_arg::<PolicyType>(args, "policy_type")?,
+        require_arg(args, "principal")?,
+        opt_arg(args, "column"),
+        bool_arg(args, "if_exists")?,
+    )
+    .await
+    .map_err(to_datafusion_error)?;
+    ok_result(ctx)
+}
+
+async fn proc_list_policies(
+    ctx: &SessionContext,
+    catalog: &Arc<dyn Catalog>,
+    catalog_name: &str,
+    args: &HashMap<String, String>,
+) -> DFResult<DataFrame> {
+    let rest = rest_catalog(catalog, catalog_name)?;
+    let request = ListPoliciesRequest {
+        resource: policy_table_resource(args)?,
+        policy_type: opt_enum_arg(args, "policy_type")?,
+        principal: opt_arg(args, "principal").map(str::to_string),
+        column: opt_arg(args, "column").map(str::to_string),
+        max_results: max_results_arg(args)?,
+        page_token: opt_arg(args, "page_token").map(str::to_string),
+    };
+    let page = rest
+        .list_policies_paged(&request)
+        .await
+        .map_err(to_datafusion_error)?;
+
+    // Same Java pagination quirk as `list_permissions`.
+    let rows = page
+        .elements
+        .iter()
+        .map(|policy| {
+            let resource = policy.resource();
+            let column_mask = policy.column_mask();
+            vec![
+                resource.database_name().map(str::to_string),
+                resource.table_name().map(str::to_string),
+                Some(policy.policy_type().to_string()),
+                Some(policy.principal().to_string()),
+                policy
+                    .row_filter()
+                    .map(|filter| filter.predicate().to_string()),
+                column_mask.map(|mask| mask.on_column().to_string()),
+                column_mask.map(|mask| mask.transform().to_string()),
+                page.next_page_token.clone(),
+            ]
+        })
+        .collect();
+
+    utf8_result(
+        ctx,
+        &[
+            ("database", false),
+            ("table", false),
+            ("policy_type", false),
+            ("principal", false),
+            ("predicate_json", true),
+            ("on_column", true),
+            ("transform_json", true),
+            ("next_page_token", true),
+        ],
+        rows,
+    )
+}
+
 fn ok_result(ctx: &SessionContext) -> DFResult<DataFrame> {
     let schema = Arc::new(Schema::new(vec![Field::new(
         "result",
diff --git a/crates/integrations/datafusion/tests/rest_management_procedures.rs 
b/crates/integrations/datafusion/tests/rest_management_procedures.rs
new file mode 100644
index 00000000..a26a405b
--- /dev/null
+++ b/crates/integrations/datafusion/tests/rest_management_procedures.rs
@@ -0,0 +1,817 @@
+// 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.
+
+mod common;
+
+#[path = "../../../paimon/tests/mock_server.rs"]
+mod mock_server;
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use arrow_array::{Array, RecordBatch};
+use paimon::api::ConfigResponse;
+use paimon::catalog::RESTCatalog;
+use paimon::{CatalogOptions, Options};
+use paimon_datafusion::SQLContext;
+
+use mock_server::{start_mock_server, RESTServer};
+
+const DATABASE: &str = "sales";
+const TABLE: &str = "orders";
+const WAREHOUSE: &str = "test_warehouse";
+
+async fn setup() -> (tempfile::TempDir, RESTServer, SQLContext) {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let server = start_mock_server(
+        WAREHOUSE.to_string(),
+        temp_dir.path().to_string_lossy().into_owned(),
+        ConfigResponse::new(HashMap::from([(
+            CatalogOptions::PREFIX.to_string(),
+            "mock-test".to_string(),
+        )])),
+        vec![DATABASE.to_string()],
+    )
+    .await;
+    server.add_table(DATABASE, TABLE);
+
+    let mut options = Options::new();
+    options.set(CatalogOptions::URI, server.url().unwrap());
+    options.set(CatalogOptions::WAREHOUSE, 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();
+    (temp_dir, server, context)
+}
+
+async fn call(context: &SQLContext, sql: &str) -> Vec<RecordBatch> {
+    context
+        .sql(sql)
+        .await
+        .unwrap_or_else(|e| panic!("planning '{sql}' failed: {e}"))
+        .collect()
+        .await
+        .unwrap_or_else(|e| panic!("'{sql}' failed: {e}"))
+}
+
+async fn columns_of(context: &SQLContext, sql: &str) -> Vec<String> {
+    context
+        .sql(sql)
+        .await
+        .unwrap()
+        .schema()
+        .fields()
+        .iter()
+        .map(|field| field.name().clone())
+        .collect()
+}
+
+fn rows(batches: &[RecordBatch]) -> Vec<Vec<Option<String>>> {
+    batches
+        .iter()
+        .flat_map(|batch| {
+            (0..batch.num_rows()).map(move |row| {
+                (0..batch.num_columns())
+                    .map(|column| {
+                        let array = batch.column(column);
+                        array
+                            .is_valid(row)
+                            .then(|| common::string_value(array.as_ref(), 
row).to_string())
+                    })
+                    .collect()
+            })
+        })
+        .collect()
+}
+
+fn cell(rows: &[Vec<Option<String>>], row: usize, column: usize) -> 
Option<&str> {
+    rows[row][column].as_deref()
+}
+
+async fn assert_ok_row(context: &SQLContext, sql: &str) {
+    let batches = call(context, sql).await;
+    assert_eq!(rows(&batches), vec![vec![Some("OK".to_string())]], "{sql}");
+}
+
+async fn grant_table_select(context: &SQLContext, principal: &str) {
+    assert_ok_row(
+        context,
+        &format!(
+            "CALL sys.grant_permission(resource_type => 'TABLE', access => 
'SELECT', \
+             principal => '{principal}', database => '{DATABASE}', table => 
'{TABLE}')"
+        ),
+    )
+    .await;
+}
+
+async fn create_row_filter(context: &SQLContext, principal: &str, predicate: 
&str) {
+    assert_ok_row(
+        context,
+        &format!(
+            "CALL sys.create_policy(database => '{DATABASE}', table => 
'{TABLE}', \
+             policy_type => 'ROW_FILTER', principal => '{principal}', \
+             predicate_json => '{predicate}')"
+        ),
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_grant_permission_sends_assignment() {
+    let (_tmp, server, context) = setup().await;
+
+    assert_ok_row(
+        &context,
+        &format!(
+            "CALL sys.grant_permission(resource_type => 'TABLE', access => 
'select', \
+             principal => 'role:analyst', database => '{DATABASE}', table => 
'{TABLE}', \
+             expire_time => '2028-01-01T00:00:00Z')"
+        ),
+    )
+    .await;
+
+    let bodies = server.grant_permission_bodies();
+    assert_eq!(bodies.len(), 1);
+    assert_eq!(
+        bodies[0],
+        serde_json::json!({
+            "resource": {"type": "TABLE", "database": "sales", "table": 
"orders"},
+            "access": "SELECT",
+            "principal": "role:analyst",
+            "expireTime": "2028-01-01T00:00:00Z",
+        })
+    );
+    assert_eq!(server.permissions().len(), 1);
+}
+
+#[tokio::test]
+async fn test_revoke_permission_sends_identity() {
+    let (_tmp, server, context) = setup().await;
+    grant_table_select(&context, "role:analyst").await;
+
+    assert_ok_row(
+        &context,
+        &format!(
+            "CALL sys.revoke_permission(resource_type => 'TABLE', access => 
'SELECT', \
+             principal => 'role:analyst', database => '{DATABASE}', table => 
'{TABLE}')"
+        ),
+    )
+    .await;
+
+    let bodies = server.revoke_permission_bodies();
+    assert_eq!(bodies.len(), 1);
+    assert_eq!(
+        bodies[0],
+        serde_json::json!({
+            "resource": {"type": "TABLE", "database": "sales", "table": 
"orders"},
+            "access": "SELECT",
+            "principal": "role:analyst",
+        })
+    );
+    assert!(server.permissions().is_empty());
+}
+
+#[tokio::test]
+async fn test_list_permissions_columns_and_rows() {
+    let (_tmp, server, context) = setup().await;
+    assert_ok_row(
+        &context,
+        &format!(
+            "CALL sys.grant_permission(resource_type => 'TABLE', access => 
'SELECT', \
+             principal => 'role:analyst', database => '{DATABASE}', table => 
'{TABLE}', \
+             expire_time => '2028-01-01T00:00:00Z')"
+        ),
+    )
+    .await;
+
+    let sql = format!(
+        "CALL sys.list_permissions(resource_type => 'TABLE', database => 
'{DATABASE}', \
+         table => '{TABLE}', principal => 'role:analyst', access => 'SELECT')"
+    );
+    assert_eq!(
+        columns_of(&context, &sql).await,
+        vec![
+            "resource_type",
+            "database",
+            "table",
+            "function",
+            "view",
+            "access",
+            "principal",
+            "column_names",
+            "excluded_column_names",
+            "expire_time",
+            "next_page_token",
+        ]
+    );
+
+    let listed = rows(&call(&context, &sql).await);
+    assert_eq!(
+        listed,
+        vec![vec![
+            Some("TABLE".to_string()),
+            Some("sales".to_string()),
+            Some("orders".to_string()),
+            None,
+            None,
+            Some("SELECT".to_string()),
+            Some("role:analyst".to_string()),
+            None,
+            None,
+            Some("2028-01-01T00:00:00Z".to_string()),
+            None,
+        ]]
+    );
+
+    let query = server.list_permissions_queries().pop().unwrap();
+    assert_eq!(query.get("resourceType").unwrap(), "TABLE");
+    assert_eq!(query.get("database").unwrap(), "sales");
+    assert_eq!(query.get("table").unwrap(), "orders");
+    assert_eq!(query.get("principal").unwrap(), "role:analyst");
+    assert_eq!(query.get("access").unwrap(), "SELECT");
+}
+
+#[tokio::test]
+async fn test_create_policy_row_filter_and_column_mask() {
+    let (_tmp, server, context) = setup().await;
+
+    create_row_filter(
+        &context,
+        "role:analyst",
+        r#"{"kind":"LEAF","name":"region"}"#,
+    )
+    .await;
+    assert_ok_row(
+        &context,
+        &format!(
+            "CALL sys.create_policy(database => '{DATABASE}', table => 
'{TABLE}', \
+             policy_type => 'COLUMN_MASKING', principal => 'role:support', \
+             on_column => 'email', transform_json => 
'{{\"name\":\"CONCAT\"}}')"
+        ),
+    )
+    .await;
+
+    let bodies = server.create_policy_bodies();
+    assert_eq!(bodies.len(), 2);
+    assert_eq!(
+        bodies[0],
+        serde_json::json!({
+            "rowFilter": {"predicate": r#"{"kind":"LEAF","name":"region"}"#},
+            "principal": "role:analyst",
+        })
+    );
+    assert_eq!(
+        bodies[1],
+        serde_json::json!({
+            "columnMask": {"onColumn": "email", "transform": 
r#"{"name":"CONCAT"}"#},
+            "principal": "role:support",
+        })
+    );
+    assert_eq!(server.table_policies(DATABASE, TABLE).len(), 2);
+}
+
+#[tokio::test]
+async fn test_drop_policy_sends_identity() {
+    let (_tmp, server, context) = setup().await;
+    create_row_filter(&context, "role:analyst", r#"{"kind":"LEAF"}"#).await;
+
+    assert_ok_row(
+        &context,
+        &format!(
+            "CALL sys.drop_policy(database => '{DATABASE}', table => 
'{TABLE}', \
+             policy_type => 'ROW_FILTER', principal => 'role:analyst')"
+        ),
+    )
+    .await;
+
+    assert_eq!(
+        server.drop_policy_bodies(),
+        vec![serde_json::json!({"type": "ROW_FILTER", "principal": 
"role:analyst"})]
+    );
+    assert!(server.table_policies(DATABASE, TABLE).is_empty());
+}
+
+#[tokio::test]
+async fn test_list_policies_columns_and_rows() {
+    let (_tmp, server, context) = setup().await;
+    create_row_filter(&context, "role:analyst", r#"{"kind":"LEAF"}"#).await;
+    assert_ok_row(
+        &context,
+        &format!(
+            "CALL sys.create_policy(database => '{DATABASE}', table => 
'{TABLE}', \
+             policy_type => 'COLUMN_MASKING', principal => 'role:support', \
+             on_column => 'email', transform_json => 
'{{\"name\":\"CONCAT\"}}')"
+        ),
+    )
+    .await;
+
+    let sql = format!("CALL sys.list_policies(database => '{DATABASE}', table 
=> '{TABLE}')");
+    assert_eq!(
+        columns_of(&context, &sql).await,
+        vec![
+            "database",
+            "table",
+            "policy_type",
+            "principal",
+            "predicate_json",
+            "on_column",
+            "transform_json",
+            "next_page_token",
+        ]
+    );
+
+    let listed = rows(&call(&context, &sql).await);
+    assert_eq!(listed.len(), 2);
+    assert_eq!(
+        listed[0],
+        vec![
+            Some("sales".to_string()),
+            Some("orders".to_string()),
+            Some("ROW_FILTER".to_string()),
+            Some("role:analyst".to_string()),
+            Some(r#"{"kind":"LEAF"}"#.to_string()),
+            None,
+            None,
+            None,
+        ]
+    );
+    assert_eq!(
+        listed[1],
+        vec![
+            Some("sales".to_string()),
+            Some("orders".to_string()),
+            Some("COLUMN_MASKING".to_string()),
+            Some("role:support".to_string()),
+            None,
+            Some("email".to_string()),
+            Some(r#"{"name":"CONCAT"}"#.to_string()),
+            None,
+        ]
+    );
+
+    assert_eq!(
+        server.list_policies_queries().pop().unwrap(),
+        HashMap::new()
+    );
+}
+
+#[tokio::test]
+async fn test_list_policies_filters() {
+    let (_tmp, server, context) = setup().await;
+    create_row_filter(&context, "role:analyst", r#"{"kind":"LEAF"}"#).await;
+    assert_ok_row(
+        &context,
+        &format!(
+            "CALL sys.create_policy(database => '{DATABASE}', table => 
'{TABLE}', \
+             policy_type => 'COLUMN_MASKING', principal => 'role:support', \
+             on_column => 'email', transform_json => 
'{{\"name\":\"CONCAT\"}}')"
+        ),
+    )
+    .await;
+
+    let listed = rows(
+        &call(
+            &context,
+            &format!(
+                "CALL sys.list_policies(database => '{DATABASE}', table => 
'{TABLE}', \
+             policy_type => 'COLUMN_MASKING', principal => 'role:support', 
column => 'email')"
+            ),
+        )
+        .await,
+    );
+    assert_eq!(listed.len(), 1);
+    assert_eq!(cell(&listed, 0, 3), Some("role:support"));
+
+    let query = server.list_policies_queries().pop().unwrap();
+    assert_eq!(query.get("type").unwrap(), "COLUMN_MASKING");
+    assert_eq!(query.get("principal").unwrap(), "role:support");
+    assert_eq!(query.get("column").unwrap(), "email");
+}
+
+#[tokio::test]
+async fn test_list_permissions_pagination_repeats_token_on_every_row() {
+    let (_tmp, server, context) = setup().await;
+    for principal in ["role:a", "role:b", "role:c"] {
+        grant_table_select(&context, principal).await;
+    }
+
+    let first = rows(
+        &call(
+            &context,
+            &format!(
+                "CALL sys.list_permissions(resource_type => 'TABLE', database 
=> '{DATABASE}', \
+             table => '{TABLE}', max_results => 2)"
+            ),
+        )
+        .await,
+    );
+    assert_eq!(first.len(), 2);
+    let token = cell(&first, 0, 10).expect("next_page_token").to_string();
+    assert_eq!(cell(&first, 1, 10), Some(token.as_str()));
+    assert_eq!(cell(&first, 0, 6), Some("role:a"));
+    assert_eq!(cell(&first, 1, 6), Some("role:b"));
+
+    let second = rows(
+        &call(
+            &context,
+            &format!(
+                "CALL sys.list_permissions(resource_type => 'TABLE', database 
=> '{DATABASE}', \
+             table => '{TABLE}', max_results => 2, page_token => '{token}')"
+            ),
+        )
+        .await,
+    );
+    assert_eq!(second.len(), 1);
+    assert_eq!(cell(&second, 0, 6), Some("role:c"));
+    assert_eq!(cell(&second, 0, 10), None);
+
+    let query = server.list_permissions_queries().pop().unwrap();
+    assert_eq!(query.get("maxResults").unwrap(), "2");
+    assert_eq!(query.get("pageToken").unwrap(), &token);
+
+    let past_end = call(
+        &context,
+        &format!(
+            "CALL sys.list_permissions(resource_type => 'TABLE', database => 
'{DATABASE}', \
+             table => '{TABLE}', max_results => 2, page_token => '3')"
+        ),
+    )
+    .await;
+    assert_eq!(rows(&past_end), Vec::<Vec<Option<String>>>::new());
+}
+
+#[tokio::test]
+async fn test_list_policies_pagination_repeats_token_on_every_row() {
+    let (_tmp, _server, context) = setup().await;
+    for principal in ["role:a", "role:b", "role:c"] {
+        create_row_filter(&context, principal, r#"{"kind":"LEAF"}"#).await;
+    }
+
+    let first = rows(
+        &call(
+            &context,
+            &format!(
+            "CALL sys.list_policies(database => '{DATABASE}', table => 
'{TABLE}', max_results => 2)"
+        ),
+        )
+        .await,
+    );
+    assert_eq!(first.len(), 2);
+    let token = cell(&first, 0, 7).expect("next_page_token").to_string();
+    assert_eq!(cell(&first, 1, 7), Some(token.as_str()));
+
+    let second = rows(
+        &call(
+            &context,
+            &format!(
+                "CALL sys.list_policies(database => '{DATABASE}', table => 
'{TABLE}', \
+             max_results => 2, page_token => '{token}')"
+            ),
+        )
+        .await,
+    );
+    assert_eq!(second.len(), 1);
+    assert_eq!(cell(&second, 0, 3), Some("role:c"));
+    assert_eq!(cell(&second, 0, 7), None);
+}
+
+#[tokio::test]
+async fn test_column_lists_are_comma_separated_and_java_trimmed() {
+    let (_tmp, server, context) = setup().await;
+
+    let empty = call(
+        &context,
+        &format!("CALL sys.list_policies(database => '{DATABASE}', table => 
'{TABLE}')"),
+    )
+    .await;
+    assert_eq!(rows(&empty), Vec::<Vec<Option<String>>>::new());
+    let list = format!(
+        "CALL sys.list_permissions(resource_type => 'COLUMN', database => 
'{DATABASE}', \
+         table => '{TABLE}')"
+    );
+
+    assert_ok_row(
+        &context,
+        &format!(
+            "CALL sys.grant_permission(resource_type => 'COLUMN', access => 
'SELECT', \
+             principal => 'role:analyst', database => '{DATABASE}', table => 
'{TABLE}', \
+             column_names => ' id , region ,\u{a0}')"
+        ),
+    )
+    .await;
+    assert_eq!(
+        server.grant_permission_bodies()[0]["columns"],
+        serde_json::json!({"columnNames": ["id", "region", "\u{a0}"]})
+    );
+    let listed = rows(&call(&context, &list).await);
+    assert_eq!(cell(&listed, 0, 7), Some("id,region,\u{a0}"));
+    assert_eq!(cell(&listed, 0, 8), None);
+
+    assert_ok_row(
+        &context,
+        &format!(
+            "CALL sys.grant_permission(resource_type => 'COLUMN', access => 
'SELECT', \
+             principal => 'role:analyst', database => '{DATABASE}', table => 
'{TABLE}', \
+             excluded_column_names => 'email,\tphone ')"
+        ),
+    )
+    .await;
+    assert_eq!(
+        server.grant_permission_bodies()[1]["columns"],
+        serde_json::json!({"excludedColumnNames": ["email", "phone"]})
+    );
+    let listed = rows(&call(&context, &list).await);
+    assert_eq!(listed.len(), 1);
+    assert_eq!(cell(&listed, 0, 7), None);
+    assert_eq!(cell(&listed, 0, 8), Some("email,phone"));
+}
+
+#[tokio::test]
+async fn test_both_column_lists_rejected() {
+    let (_tmp, _server, context) = setup().await;
+    common::assert_sql_error(
+        &context,
+        &format!(
+            "CALL sys.grant_permission(resource_type => 'COLUMN', access => 
'SELECT', \
+             principal => 'role:analyst', database => '{DATABASE}', table => 
'{TABLE}', \
+             column_names => 'id', excluded_column_names => 'email')"
+        ),
+        "exactly one of column_names or excluded_column_names",
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_drop_policy_if_exists_swallows_missing_policy() {
+    let (_tmp, _server, context) = setup().await;
+
+    assert_ok_row(
+        &context,
+        &format!(
+            "CALL sys.drop_policy(database => '{DATABASE}', table => 
'{TABLE}', \
+             policy_type => 'ROW_FILTER', principal => 'role:ghost', if_exists 
=> true)"
+        ),
+    )
+    .await;
+
+    common::assert_sql_error(
+        &context,
+        &format!(
+            "CALL sys.drop_policy(database => '{DATABASE}', table => 
'{TABLE}', \
+             policy_type => 'ROW_FILTER', principal => 'role:ghost', if_exists 
=> false)"
+        ),
+        "Policy does not exist",
+    )
+    .await;
+
+    common::assert_sql_error(
+        &context,
+        &format!(
+            "CALL sys.drop_policy(database => '{DATABASE}', table => 
'{TABLE}', \
+             policy_type => 'ROW_FILTER', principal => 'role:ghost')"
+        ),
+        "Policy does not exist",
+    )
+    .await;
+
+    common::assert_sql_error(
+        &context,
+        &format!(
+            "CALL sys.drop_policy(database => '{DATABASE}', table => 
'{TABLE}', \
+             policy_type => 'ROW_FILTER', principal => 'role:ghost', if_exists 
=> 'yes')"
+        ),
+        "Invalid if_exists 'yes'",
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_policy_type_rejects_the_other_type_fields() {
+    let (_tmp, _server, context) = setup().await;
+
+    common::assert_sql_error(
+        &context,
+        &format!(
+            "CALL sys.create_policy(database => '{DATABASE}', table => 
'{TABLE}', \
+             policy_type => 'ROW_FILTER', principal => 'p', predicate_json => 
'{{}}', \
+             on_column => 'email')"
+        ),
+        "ROW_FILTER policy cannot specify on_column.",
+    )
+    .await;
+
+    common::assert_sql_error(
+        &context,
+        &format!(
+            "CALL sys.create_policy(database => '{DATABASE}', table => 
'{TABLE}', \
+             policy_type => 'COLUMN_MASKING', principal => 'p', predicate_json 
=> '{{}}', \
+             on_column => 'email', transform_json => '{{}}')"
+        ),
+        "COLUMN_MASKING policy cannot specify predicate_json.",
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_non_rest_catalog_is_rejected_by_every_procedure() {
+    let (_tmp, catalog) = common::create_test_env();
+    let context = common::create_sql_context(catalog).await;
+
+    for sql in [
+        "CALL sys.grant_permission(resource_type => 'CATALOG', access => 
'CREATEDATABASE', principal => 'admin')",
+        "CALL sys.revoke_permission(resource_type => 'CATALOG', access => 
'CREATEDATABASE', principal => 'admin')",
+        "CALL sys.list_permissions(resource_type => 'CATALOG')",
+        "CALL sys.create_policy(database => 'sales', table => 'orders', 
policy_type => 'ROW_FILTER', principal => 'p', predicate_json => '{}')",
+        "CALL sys.drop_policy(database => 'sales', table => 'orders', 
policy_type => 'ROW_FILTER', principal => 'p')",
+        "CALL sys.list_policies(database => 'sales', table => 'orders')",
+    ] {
+        common::assert_sql_error(
+            &context,
+            sql,
+            "does not support permission or policy management",
+        )
+        .await;
+    }
+}
+
+#[tokio::test]
+async fn test_argument_validation_follows_java() {
+    let (_tmp, server, context) = setup().await;
+
+    let error = context
+        .sql(&format!(
+            "CALL sys.grant_permission(resource_type => 'TABLE', database => 
'{DATABASE}', \
+             table => '{TABLE}', access => 'SELECT', principal => 'analyst', \
+             expire_time => '\u{a0}')"
+        ))
+        .await
+        .expect_err("a non-blank expire_time that is not a timestamp must be 
rejected")
+        .to_string();
+    assert!(error.contains("ISO-8601"), "{error}");
+
+    context
+        .sql(&format!(
+            "CALL sys.grant_permission(resource_type => 'TABLE', database => 
'{DATABASE}', \
+             table => '{TABLE}', access => 'SELECT', principal => 'analyst', \
+             expire_time => '\u{0}')"
+        ))
+        .await
+        .unwrap();
+    let body = server.grant_permission_bodies().pop().unwrap();
+    assert!(
+        body.get("expireTime").is_none(),
+        "a NUL-only expire_time is blank to Java and must not be sent: {body}"
+    );
+
+    let error = context
+        .sql("CALL sys.list_permissions(resource_type => ' TABLE ')")
+        .await
+        .unwrap_err()
+        .to_string();
+    assert!(error.contains("Invalid resource_type"), "{error}");
+    let error = context
+        .sql("CALL sys.list_permissions(resource_type => '  ')")
+        .await
+        .unwrap_err()
+        .to_string();
+    assert!(error.contains("resource_type cannot be empty"), "{error}");
+
+    for (sql, expected) in [
+        (
+            "CALL sys.grant_permission(resource_type => 'TABEL', access => 
'SELECT', \
+             principal => 'role:analyst')"
+                .to_string(),
+            "Invalid resource_type 'TABEL'. Expected one of [CATALOG, 
CATALOG_ALL, \
+             DATABASE, DATABASE_ALL, TABLE, COLUMN, VIEW, FUNCTION].",
+        ),
+        (
+            format!(
+                "CALL sys.list_policies(database => '{DATABASE}', table => 
'{TABLE}', \
+                 policy_type => 'ROW_FILTERS')"
+            ),
+            "Invalid policy_type 'ROW_FILTERS'. Expected one of [ROW_FILTER, 
COLUMN_MASKING].",
+        ),
+        (
+            "CALL sys.list_permissions(database => 'sales')".to_string(),
+            "Missing required argument: 'resource_type'",
+        ),
+    ] {
+        common::assert_sql_error(&context, &sql, expected).await;
+    }
+}
+
+#[tokio::test]
+async fn test_unknown_and_duplicate_arguments_are_rejected() {
+    let (_tmp, server, context) = setup().await;
+
+    let grant = |extra: &str| {
+        format!(
+            "CALL sys.grant_permission(resource_type => 'TABLE', database => 
'{DATABASE}', \
+             table => '{TABLE}', access => 'SELECT', principal => 
'role:analyst', {extra})"
+        )
+    };
+
+    common::assert_sql_error(
+        &context,
+        &grant("expiretime => '2030-01-01T00:00:00Z'"),
+        "Argument expiretime is unknown.",
+    )
+    .await;
+
+    common::assert_sql_error(
+        &context,
+        &grant("principal => 'role:other'"),
+        "Procedure argument principal is duplicated.",
+    )
+    .await;
+
+    common::assert_sql_error(
+        &context,
+        &format!(
+            "CALL sys.list_policies(database => '{DATABASE}', table => 
'{TABLE}', \
+             page_tokn => '1')"
+        ),
+        "Argument page_tokn is unknown.",
+    )
+    .await;
+
+    assert!(server.grant_permission_bodies().is_empty());
+    assert!(server.list_policies_queries().is_empty());
+}
+
+#[tokio::test]
+async fn test_view_and_function_resources_round_trip() {
+    let (_tmp, server, context) = setup().await;
+
+    for (resource_type, locator, name) in [("VIEW", "view", "v1"), 
("FUNCTION", "function", "f1")] {
+        assert_ok_row(
+            &context,
+            &format!(
+                "CALL sys.grant_permission(resource_type => '{resource_type}', 
\
+                 database => '{DATABASE}', {locator} => '{name}', access => 
'SELECT', \
+                 principal => 'role:analyst')"
+            ),
+        )
+        .await;
+
+        let listed = rows(
+            &call(
+                &context,
+                &format!(
+                    "CALL sys.list_permissions(resource_type => 
'{resource_type}', \
+                     database => '{DATABASE}', {locator} => '{name}')"
+                ),
+            )
+            .await,
+        );
+        assert_eq!(cell(&listed, 0, 0), Some(resource_type));
+        assert_eq!(cell(&listed, 0, 1), Some(DATABASE));
+        assert_eq!(cell(&listed, 0, 2), None);
+        // Column 3 is `function` and column 4 is `view`, in Java's order.
+        let (function, view) = (cell(&listed, 0, 3), cell(&listed, 0, 4));
+        if resource_type == "VIEW" {
+            assert_eq!((function, view), (None, Some(name)));
+        } else {
+            assert_eq!((function, view), (Some(name), None));
+        }
+    }
+    assert_eq!(server.grant_permission_bodies().len(), 2);
+}
+
+#[tokio::test]
+async fn test_all_three_procedure_name_forms_resolve() {
+    let (_tmp, server, context) = setup().await;
+
+    for name in [
+        "grant_permission",
+        "sys.grant_permission",
+        "paimon.sys.grant_permission",
+    ] {
+        assert_ok_row(
+            &context,
+            &format!(
+                "CALL {name}(resource_type => 'TABLE', access => 'SELECT', \
+                 principal => 'role:analyst', database => '{DATABASE}', table 
=> '{TABLE}')"
+            ),
+        )
+        .await;
+    }
+    assert_eq!(server.grant_permission_bodies().len(), 3);
+    assert_eq!(server.permissions().len(), 1);
+}
diff --git a/crates/paimon/src/catalog/filesystem.rs 
b/crates/paimon/src/catalog/filesystem.rs
index 743f0028..d660697f 100644
--- a/crates/paimon/src/catalog/filesystem.rs
+++ b/crates/paimon/src/catalog/filesystem.rs
@@ -288,6 +288,12 @@ impl FileSystemCatalog {
 
 #[async_trait]
 impl Catalog for FileSystemCatalog {
+    /// Opts in so that a failed downcast means "wrong catalog", not "never 
opted in".
+    /// Do not delete: it is what makes the REST-only capability checks test 
what they claim.
+    fn as_any(&self) -> Option<&dyn std::any::Any> {
+        Some(self)
+    }
+
     async fn list_databases(&self) -> Result<Vec<String>> {
         let dirs = self.list_directories(&self.warehouse).await?;
         Ok(dirs
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index 0c66f8be..b610680c 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -347,6 +347,13 @@ impl LoadedTable {
 /// Corresponds to 
[org.apache.paimon.catalog.Catalog](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java).
 #[async_trait]
 pub trait Catalog: Send + Sync {
+    /// Hook for catalog-specific APIs that are not on this trait, as Java 
reaches `RESTCatalog`
+    /// through `DelegateCatalog.rootCatalog(...) instanceof RESTCatalog`. 
Defaults to `None`;
+    /// override as `Some(self)` in a catalog that has such APIs.
+    fn as_any(&self) -> Option<&dyn std::any::Any> {
+        None
+    }
+
     // ======================= database methods ===============================
 
     /// List names of all databases in this catalog.
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs 
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index 724dbcdb..a7e410cb 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -198,6 +198,9 @@ impl RESTCatalog {
 
 #[async_trait]
 impl Catalog for RESTCatalog {
+    fn as_any(&self) -> Option<&dyn std::any::Any> {
+        Some(self)
+    }
     // ======================= database methods ===============================
 
     async fn list_databases(&self) -> Result<Vec<String>> {
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 7848f91c..6cad8cfe 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1356,6 +1356,123 @@ CALL sys.create_lumina_index(
 );
 ```
 
+### grant_permission
+
+Grant an access to a principal on a REST catalog. Only the REST catalog 
supports
+permission and policy management:
+
+```sql
+CALL sys.grant_permission(
+  resource_type => 'TABLE',
+  database => 'sales',
+  table => 'orders',
+  access => 'SELECT',
+  principal => 'user:alice'
+);
+```
+
+`resource_type` is one of `CATALOG`, `CATALOG_ALL`, `DATABASE`, `DATABASE_ALL`,
+`TABLE`, `COLUMN`, `VIEW` or `FUNCTION`, and decides which locators are 
required:
+`DATABASE` and `DATABASE_ALL` need `database`, while `TABLE`, `COLUMN`, `VIEW`
+and `FUNCTION` need `database` plus the matching `table`, `view` or `function`.
+`expire_time` is an ISO-8601 UTC instant ending in `Z`.
+
+A `COLUMN` grant narrows an access to part of a table and takes exactly one of
+`column_names` or `excluded_column_names`. **Both are comma-separated strings
+here**, where Java's Spark procedure declares `ARRAY<STRING>`, because a `CALL`
+argument in this engine is always a scalar:
+
+```sql
+CALL sys.grant_permission(
+  resource_type => 'COLUMN',
+  database => 'sales',
+  table => 'orders',
+  access => 'SELECT',
+  principal => 'role:analyst',
+  column_names => 'id, region'
+);
+```
+
+### revoke_permission
+
+Remove an access from a principal. Revoking one that was never granted 
succeeds:
+
+```sql
+CALL sys.revoke_permission(
+  resource_type => 'TABLE',
+  database => 'sales',
+  table => 'orders',
+  access => 'SELECT',
+  principal => 'user:alice'
+);
+```
+
+### list_permissions
+
+List the assignments on an exact resource, optionally filtered by `principal`
+and `access`, and paged with `max_results` and `page_token`. Only grants made 
on
+that exact resource are returned, so a `DATABASE_ALL` or `COLUMN` grant that 
also
+covers the table does not appear:
+
+```sql
+CALL sys.list_permissions(
+  resource_type => 'TABLE',
+  database => 'sales',
+  table => 'orders'
+);
+```
+
+Every row carries `next_page_token`, so an empty page returns no rows and no
+token. The `column_names` and `excluded_column_names` columns are comma-joined.
+
+### create_policy
+
+Attach a row filter or a column mask to a table for one principal. A policy
+carries exactly one of the two, so `ROW_FILTER` takes `predicate_json` and
+rejects `on_column` and `transform_json`, while `COLUMN_MASKING` takes
+`on_column` and `transform_json` and rejects `predicate_json`:
+
+```sql
+CALL sys.create_policy(
+  database => 'sales',
+  table => 'orders',
+  policy_type => 'ROW_FILTER',
+  principal => 'role:analyst',
+  predicate_json => 
'{"kind":"LEAF","transform":{"name":"FIELD_REF","fieldRef":{"index":1,"name":"region","type":"STRING"}},"function":"EQUAL","literals":["APAC"]}'
+);
+```
+
+`predicate_json` and `transform_json` are serialized Paimon `Predicate` and
+`Transform` values, not SQL. They are the same JSON the catalog returns for a
+table's query authorization.
+
+### drop_policy
+
+Remove a policy by its identity, which is the table, the principal, the type,
+and for a column mask the column. `if_exists => 'true'` swallows a missing
+policy, but not a missing table:
+
+```sql
+CALL sys.drop_policy(
+  database => 'sales',
+  table => 'orders',
+  policy_type => 'COLUMN_MASKING',
+  principal => 'role:analyst',
+  column => 'email',
+  if_exists => 'true'
+);
+```
+
+### list_policies
+
+List the policies on a table, optionally filtered by `policy_type` and
+`principal`, and paged like `list_permissions`. A `column` filter is only
+meaningful for a column mask, so it requires `policy_type => 'COLUMN_MASKING'`:
+
+```sql
+CALL sys.list_policies(database => 'sales', table => 'orders');
+```
+
 ## Queries
 
 ### Basic Queries

Reply via email to