This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 8de456de10 [core][spark] Add REST management procedures (#9410)
8de456de10 is described below
commit 8de456de1017b3784406f51ab72f5033e09625d8
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Aug 26 22:31:50 2026 +0800
[core][spark] Add REST management procedures (#9410)
---
docs/docs/concepts/rest/management-api.md | 326 +++++++++
.../PaimonSqlExtensions.g4 | 8 +-
.../org/apache/paimon/spark/SparkProcedures.java | 12 +
.../spark/procedure/BasePermissionProcedure.java | 130 ++++
.../spark/procedure/BasePolicyProcedure.java | 62 ++
.../spark/procedure/CreatePolicyProcedure.java | 100 +++
.../spark/procedure/DropPolicyProcedure.java | 94 +++
.../spark/procedure/GrantPermissionProcedure.java | 139 ++++
.../spark/procedure/ListPermissionsProcedure.java | 160 +++++
.../spark/procedure/ListPoliciesProcedure.java | 140 ++++
.../spark/procedure/RevokePermissionProcedure.java | 96 +++
.../AbstractPaimonSparkSqlExtensionsParser.scala | 1 +
.../spark/PaimonSparkTestWithRestCatalogBase.scala | 16 +-
.../spark/procedure/PermissionProcedureTest.scala | 787 +++++++++++++++++++++
.../sql/CatalogQualifiedCreateTableLikeTest.scala | 5 +
15 files changed, 2073 insertions(+), 3 deletions(-)
diff --git a/docs/docs/concepts/rest/management-api.md
b/docs/docs/concepts/rest/management-api.md
index 751eb6f3af..e90e9a71d4 100644
--- a/docs/docs/concepts/rest/management-api.md
+++ b/docs/docs/concepts/rest/management-api.md
@@ -231,3 +231,329 @@ schema-incompatible predicate or transform must also fail
closed rather than omi
This experimental contract deliberately does not define governed tags,
catalog/database policy
inheritance, or tag-driven matching. Those features need explicit match
conditions and conflict
rules before being added.
+
+## Spark SQL procedures
+
+The following examples assume a Spark catalog named `paimon`. Replace it with
the catalog name in
+`spark.sql.catalog.<catalog-name>`.
+
+### Grant permissions
+
+`grant_permission` returns one row with `result = true` when the server
accepts the assignment.
+
+Grant permission to create databases in the catalog:
+
+```sql
+CALL paimon.sys.grant_permission(
+ resource_type => 'CATALOG',
+ access => 'CREATEDATABASE',
+ principal => 'role:catalog_user'
+);
+```
+
+Grant read access to every applicable object currently or subsequently created
in the catalog. This
+does not grant catalog-level operations such as `CREATEDATABASE`:
+
+```sql
+CALL paimon.sys.grant_permission(
+ resource_type => 'CATALOG_ALL',
+ access => 'SELECT',
+ principal => 'role:catalog_reader'
+);
+```
+
+Grant permission to create views in a database with an optional expiration
time:
+
+```sql
+CALL paimon.sys.grant_permission(
+ resource_type => 'DATABASE',
+ database => 'sales',
+ access => 'CREATEVIEW',
+ principal => 'role:data_engineer',
+ expire_time => '2027-01-01T00:00:00Z'
+);
+```
+
+Grant write access to every applicable table currently or subsequently created
in one database.
+`DATABASE_ALL` requires `database` but does not accept a table, function, or
view locator:
+
+```sql
+CALL paimon.sys.grant_permission(
+ resource_type => 'DATABASE_ALL',
+ database => 'sales',
+ access => 'UPDATE',
+ principal => 'role:sales_writer'
+);
+```
+
+Grant table, function, and view access with the matching locator:
+
+```sql
+CALL paimon.sys.grant_permission(
+ resource_type => 'TABLE',
+ database => 'sales',
+ table => 'orders',
+ access => 'SELECT',
+ principal => 'user:alice'
+);
+
+CALL paimon.sys.grant_permission(
+ resource_type => 'FUNCTION',
+ database => 'sales',
+ function => 'calculate_tax',
+ access => 'SELECT',
+ principal => 'role:analyst'
+);
+
+CALL paimon.sys.grant_permission(
+ resource_type => 'VIEW',
+ database => 'sales',
+ view => 'daily_orders',
+ access => 'SELECT',
+ principal => 'service:reporting_job'
+);
+```
+
+Grant access to selected columns. This requires table query authorization;
named arguments are
+recommended because the two column range modes are mutually exclusive:
+
+```sql
+ALTER TABLE paimon.sales.orders
+SET TBLPROPERTIES ('query-auth.enabled' = 'true');
+
+CALL paimon.sys.grant_permission(
+ resource_type => 'COLUMN',
+ database => 'sales',
+ table => 'orders',
+ access => 'SELECT',
+ principal => 'role:analyst',
+ column_names => array('order_id', 'region')
+);
+```
+
+Use `excluded_column_names` for a denylist. Repeating the grant replaces the
preceding allowlist in
+one operation:
+
+```sql
+CALL paimon.sys.grant_permission(
+ resource_type => 'COLUMN',
+ database => 'sales',
+ table => 'orders',
+ access => 'SELECT',
+ principal => 'role:analyst',
+ excluded_column_names => array('email', 'phone_number')
+);
+```
+
+### List permissions
+
+`list_permissions` always addresses one exact resource or explicit descendant
scope. Omit optional
+filters to list every direct assignment on it; effective assignments inherited
from a scope are not
+synthesized:
+
+```sql
+CALL paimon.sys.list_permissions(
+ resource_type => 'TABLE',
+ database => 'sales',
+ table => 'orders'
+);
+```
+
+Filter by principal or access:
+
+```sql
+CALL paimon.sys.list_permissions(
+ resource_type => 'TABLE',
+ database => 'sales',
+ table => 'orders',
+ principal => 'role:analyst',
+ access => 'SELECT'
+);
+```
+
+List the column range attached to a principal. The result exposes
`column_names` and
+`excluded_column_names` as `ARRAY<STRING>` columns, with exactly one populated
for a `COLUMN`
+assignment:
+
+```sql
+CALL paimon.sys.list_permissions(
+ resource_type => 'COLUMN',
+ database => 'sales',
+ table => 'orders',
+ principal => 'role:analyst',
+ access => 'SELECT'
+);
+```
+
+The `next_page_token` output is opaque; pass it back unchanged with the same
filters:
+
+```sql
+CALL paimon.sys.list_permissions(
+ resource_type => 'TABLE',
+ database => 'sales',
+ table => 'orders',
+ max_results => 50,
+ page_token => 'opaque-token-from-previous-row'
+);
+```
+
+### Revoke permissions
+
+Supply the same three identity fields used by the grant. `expire_time` is not
part of identity.
+
+```sql
+CALL paimon.sys.revoke_permission(
+ resource_type => 'TABLE',
+ database => 'sales',
+ table => 'orders',
+ access => 'SELECT',
+ principal => 'role:sales_reader'
+);
+```
+
+Repeating the same call succeeds even when the assignment is already absent.
+
+Column revocation uses the containing table identity and removes the complete
range:
+
+```sql
+CALL paimon.sys.revoke_permission(
+ resource_type => 'COLUMN',
+ database => 'sales',
+ table => 'orders',
+ access => 'SELECT',
+ principal => 'role:analyst'
+);
+```
+
+### Create row-filter policies
+
+Before attaching any policy, enable table query authorization:
+
+```sql
+ALTER TABLE paimon.sales.orders
+SET TBLPROPERTIES ('query-auth.enabled' = 'true');
+```
+
+`create_policy` accepts the canonical `principal` and a serialized Paimon
`Predicate`. The JSON
+below is the same representation accepted in one
`AuthTableQueryResponse.filter` entry. Named
+arguments are recommended because row-filter and column-mask definitions use
different fields:
+
+```sql
+CALL paimon.sys.create_policy(
+ database => 'sales',
+ table => 'orders',
+ policy_type => 'ROW_FILTER',
+ principal => 'group:analysts',
+ predicate_json =>
'{"kind":"LEAF","transform":{"name":"FIELD_REF","fieldRef":{"index":1,"name":"region","type":"STRING"}},"function":"EQUAL","literals":["APAC"]}'
+);
+```
+
+The call fails if that principal already has a row filter on the table. Drop
the existing policy
+before creating a different definition for the same identity. Create another
policy for a second
+principal with a separate call.
+
+### Create column-masking policies
+
+For column masking, `on_column` identifies the protected column and
`transform_json` is the same
+serialized Paimon `Transform` representation used as an
+`AuthTableQueryResponse.columnMasking` value. This example replaces every
visible phone number
+with a fixed string:
+
+```sql
+CALL paimon.sys.create_policy(
+ database => 'sales',
+ table => 'customers',
+ policy_type => 'COLUMN_MASKING',
+ principal => 'role:support',
+ on_column => 'phone_number',
+ transform_json => '{"name":"CONCAT","inputs":["****"]}'
+);
+```
+
+A transform may reference table fields by name. The server remaps their
indices to the current
+schema, rejects missing fields, and verifies that the result type matches
`on_column`:
+
+```sql
+CALL paimon.sys.create_policy(
+ database => 'sales',
+ table => 'customers',
+ policy_type => 'COLUMN_MASKING',
+ principal => 'group:support',
+ on_column => 'email',
+ transform_json =>
'{"name":"CONCAT","inputs":[{"index":1,"name":"region","type":"STRING"},"-masked"]}'
+);
+```
+
+`predicate_json` is required only for `ROW_FILTER`. `on_column` and
`transform_json` are required
+only for `COLUMN_MASKING`. JSON containing a single quote must escape it as
`''` inside the SQL
+string literal.
+
+### List policies
+
+List every policy directly attached to one table:
+
+```sql
+CALL paimon.sys.list_policies(
+ database => 'sales',
+ table => 'orders'
+);
+```
+
+Filter by policy type or principal. A `column` filter is valid only with
+`policy_type => 'COLUMN_MASKING'`:
+
+```sql
+CALL paimon.sys.list_policies(
+ database => 'sales',
+ table => 'orders',
+ policy_type => 'ROW_FILTER',
+ principal => 'group:analysts'
+);
+```
+
+The output columns are `database`, `table`, `policy_type`, `principal`,
`predicate_json`,
+`on_column`, `transform_json`, and `next_page_token`. A row filter has only
`predicate_json`; a
+column mask has only `on_column` and `transform_json`. Pass an opaque
continuation token back
+unchanged with the same filters:
+
+```sql
+CALL paimon.sys.list_policies(
+ database => 'sales',
+ table => 'orders',
+ max_results => 50,
+ page_token => 'opaque-token-from-previous-row'
+);
+```
+
+Management listing follows the existing Paimon pagination contract: an empty
page terminates
+pagination and therefore has no continuation token. Each Spark procedure
returns exactly the page
+selected by `page_token`; pass a non-null `next_page_token` back unchanged to
retrieve the next page.
+
+### Drop policies
+
+Drop an existing policy:
+
+```sql
+CALL paimon.sys.drop_policy(
+ database => 'sales',
+ table => 'orders',
+ policy_type => 'ROW_FILTER',
+ principal => 'group:analysts'
+);
+```
+
+By default an absent policy is an error. Set `if_exists => true` for an
idempotent operation:
+
+```sql
+CALL paimon.sys.drop_policy(
+ database => 'sales',
+ table => 'orders',
+ policy_type => 'ROW_FILTER',
+ principal => 'group:analysts',
+ if_exists => true
+);
+```
+
+Creating, dropping, or inspecting permissions and policies requires the server
to
+authorize the caller for `GRANT` on the relevant resource. Authentication,
principal
+membership, policy persistence, schema validation, and audit logging remain
REST server concerns.
diff --git
a/paimon-spark/paimon-spark-common/src/main/antlr4/org.apache.spark.sql.catalyst.parser.extensions/PaimonSqlExtensions.g4
b/paimon-spark/paimon-spark-common/src/main/antlr4/org.apache.spark.sql.catalyst.parser.extensions/PaimonSqlExtensions.g4
index 8c2e45b34e..bbd2728d9d 100644
---
a/paimon-spark/paimon-spark-common/src/main/antlr4/org.apache.spark.sql.catalyst.parser.extensions/PaimonSqlExtensions.g4
+++
b/paimon-spark/paimon-spark-common/src/main/antlr4/org.apache.spark.sql.catalyst.parser.extensions/PaimonSqlExtensions.g4
@@ -167,6 +167,7 @@ overwriteClause
expression
: constant
| stringMap
+ | stringArray
;
constant
@@ -180,6 +181,10 @@ stringMap
: MAP '(' constant (',' constant)* ')'
;
+stringArray
+ : ARRAY '(' (constant (',' constant)*)? ')'
+ ;
+
booleanValue
: TRUE | FALSE
;
@@ -214,7 +219,7 @@ nonReserved
: ALTER | AS | CALL | CREATE | DAYS | DELETE | EXISTS | HOURS | IF | LIKE
| NOT | OF | OR | TABLE | REPLACE | RETAIN | VERSION | TAG
| TRUE | FALSE
- | MAP
+ | ARRAY | MAP
| COPY | INTO | FROM | FILE_FORMAT | PATTERN | FORCE | ON_ERROR |
ABORT_STATEMENT | CONTINUE | SKIP_FILE | OVERWRITE
| CSV
| JSON
@@ -249,6 +254,7 @@ TRUE: 'TRUE';
FALSE: 'FALSE';
MAP: 'MAP';
+ARRAY: 'ARRAY';
COPY: 'COPY';
INTO: 'INTO';
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
index 60b5747e3d..866a54e802 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
@@ -29,16 +29,21 @@ import org.apache.paimon.spark.procedure.CopyFilesProcedure;
import org.apache.paimon.spark.procedure.CreateBranchProcedure;
import org.apache.paimon.spark.procedure.CreateFunctionProcedure;
import org.apache.paimon.spark.procedure.CreateGlobalIndexProcedure;
+import org.apache.paimon.spark.procedure.CreatePolicyProcedure;
import org.apache.paimon.spark.procedure.CreateTagFromTimestampProcedure;
import org.apache.paimon.spark.procedure.CreateTagProcedure;
import org.apache.paimon.spark.procedure.DeleteBranchProcedure;
import org.apache.paimon.spark.procedure.DeleteTagProcedure;
import org.apache.paimon.spark.procedure.DropFunctionProcedure;
import org.apache.paimon.spark.procedure.DropGlobalIndexProcedure;
+import org.apache.paimon.spark.procedure.DropPolicyProcedure;
import org.apache.paimon.spark.procedure.ExpirePartitionsProcedure;
import org.apache.paimon.spark.procedure.ExpireSnapshotsProcedure;
import org.apache.paimon.spark.procedure.ExpireTagsProcedure;
import org.apache.paimon.spark.procedure.FastForwardProcedure;
+import org.apache.paimon.spark.procedure.GrantPermissionProcedure;
+import org.apache.paimon.spark.procedure.ListPermissionsProcedure;
+import org.apache.paimon.spark.procedure.ListPoliciesProcedure;
import org.apache.paimon.spark.procedure.MarkPartitionDoneProcedure;
import org.apache.paimon.spark.procedure.MaterializeDeletionVectorsProcedure;
import org.apache.paimon.spark.procedure.MergeBranchProcedure;
@@ -57,6 +62,7 @@ import org.apache.paimon.spark.procedure.RepairProcedure;
import org.apache.paimon.spark.procedure.ReplaceTagProcedure;
import org.apache.paimon.spark.procedure.RescaleProcedure;
import org.apache.paimon.spark.procedure.ResetConsumerProcedure;
+import org.apache.paimon.spark.procedure.RevokePermissionProcedure;
import org.apache.paimon.spark.procedure.RewriteFileIndexProcedure;
import org.apache.paimon.spark.procedure.RollbackProcedure;
import org.apache.paimon.spark.procedure.RollbackToTimestampProcedure;
@@ -134,6 +140,12 @@ public class SparkProcedures {
procedureBuilders.put("rewrite_file_index",
RewriteFileIndexProcedure::builder);
procedureBuilders.put("copy", CopyFilesProcedure::builder);
procedureBuilders.put("reassign_row_id",
ReassignRowIdProcedure::builder);
+ procedureBuilders.put("grant_permission",
GrantPermissionProcedure::builder);
+ procedureBuilders.put("revoke_permission",
RevokePermissionProcedure::builder);
+ procedureBuilders.put("list_permissions",
ListPermissionsProcedure::builder);
+ procedureBuilders.put("create_policy", CreatePolicyProcedure::builder);
+ procedureBuilders.put("drop_policy", DropPolicyProcedure::builder);
+ procedureBuilders.put("list_policies", ListPoliciesProcedure::builder);
return procedureBuilders.build();
}
}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/BasePermissionProcedure.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/BasePermissionProcedure.java
new file mode 100644
index 0000000000..8a4ba0b069
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/BasePermissionProcedure.java
@@ -0,0 +1,130 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.spark.procedure;
+
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.DelegateCatalog;
+import org.apache.paimon.management.PermissionAssignment;
+import org.apache.paimon.management.PermissionColumns;
+import org.apache.paimon.management.PermissionManagement;
+import org.apache.paimon.management.PermissionResource;
+import org.apache.paimon.management.PolicyManagement;
+import org.apache.paimon.management.ResourceType;
+import org.apache.paimon.rest.RESTCatalog;
+import org.apache.paimon.spark.catalog.WithPaimonCatalog;
+
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+
+import javax.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.Locale;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Shared REST catalog lookup and argument validation for management
procedures. */
+abstract class BasePermissionProcedure extends BaseProcedure {
+
+ protected BasePermissionProcedure(TableCatalog tableCatalog) {
+ super(tableCatalog);
+ }
+
+ protected PermissionManagement permissionManagement() {
+ return restCatalog().permissionManagement();
+ }
+
+ protected PolicyManagement policyManagement() {
+ return restCatalog().policyManagement();
+ }
+
+ private RESTCatalog restCatalog() {
+ checkArgument(
+ tableCatalog() instanceof WithPaimonCatalog,
+ "Catalog '%s' is not a Paimon catalog.",
+ tableCatalog().name());
+ Catalog root =
+ DelegateCatalog.rootCatalog(((WithPaimonCatalog)
tableCatalog()).paimonCatalog());
+ checkArgument(
+ root instanceof RESTCatalog,
+ "Catalog '%s' does not support permission or policy
management.",
+ tableCatalog().name());
+ return (RESTCatalog) root;
+ }
+
+ protected static PermissionAssignment assignment(
+ ResourceType resourceType,
+ String access,
+ String principal,
+ @Nullable String database,
+ @Nullable String table,
+ @Nullable String function,
+ @Nullable String view,
+ @Nullable PermissionColumns columns,
+ @Nullable String expireTime) {
+ return new PermissionAssignment(
+ resource(resourceType, database, table, function, view),
+ access,
+ principal,
+ columns,
+ emptyToNull(expireTime));
+ }
+
+ protected static PermissionResource resource(
+ ResourceType resourceType,
+ @Nullable String database,
+ @Nullable String table,
+ @Nullable String function,
+ @Nullable String view) {
+ return new PermissionResource(
+ resourceType,
+ emptyToNull(database),
+ emptyToNull(table),
+ emptyToNull(function),
+ emptyToNull(view));
+ }
+
+ protected static <E extends Enum<E>> E enumValue(
+ String value, Class<E> enumClass, String argument) {
+ checkArgument(!isBlank(value), "%s cannot be empty.", argument);
+ try {
+ return Enum.valueOf(enumClass, value.toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Invalid %s '%s'. Expected one of %s.",
+ argument, value,
Arrays.toString(enumClass.getEnumConstants())),
+ e);
+ }
+ }
+
+ @Nullable
+ protected static <E extends Enum<E>> E optionalEnum(
+ @Nullable String value, Class<E> enumClass, String argument) {
+ return isBlank(value) ? null : enumValue(value, enumClass, argument);
+ }
+
+ @Nullable
+ protected static String emptyToNull(@Nullable String value) {
+ return isBlank(value) ? null : value;
+ }
+
+ protected static boolean isBlank(@Nullable String value) {
+ return value == null || value.trim().isEmpty();
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/BasePolicyProcedure.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/BasePolicyProcedure.java
new file mode 100644
index 0000000000..e96d31df77
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/BasePolicyProcedure.java
@@ -0,0 +1,62 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.spark.procedure;
+
+import org.apache.paimon.management.ColumnMask;
+import org.apache.paimon.management.DataPolicy;
+import org.apache.paimon.management.PermissionResource;
+import org.apache.paimon.management.PolicyType;
+import org.apache.paimon.management.ResourceType;
+import org.apache.paimon.management.RowFilter;
+
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+
+import javax.annotation.Nullable;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Shared typed argument conversion for table policy procedures. */
+abstract class BasePolicyProcedure extends BasePermissionProcedure {
+
+ protected BasePolicyProcedure(TableCatalog tableCatalog) {
+ super(tableCatalog);
+ }
+
+ protected static DataPolicy policy(
+ String database,
+ String table,
+ PolicyType policyType,
+ String principal,
+ @Nullable String predicate,
+ @Nullable String onColumn,
+ @Nullable String transform) {
+ PermissionResource resource = tableResource(database, table);
+ if (policyType == PolicyType.ROW_FILTER) {
+ checkArgument(isBlank(onColumn), "ROW_FILTER policy cannot specify
on_column.");
+ checkArgument(isBlank(transform), "ROW_FILTER policy cannot
specify transform.");
+ return DataPolicy.rowFilter(resource, new RowFilter(predicate),
principal);
+ }
+ checkArgument(isBlank(predicate), "COLUMN_MASKING policy cannot
specify predicate.");
+ return DataPolicy.columnMask(resource, new ColumnMask(onColumn,
transform), principal);
+ }
+
+ protected static PermissionResource tableResource(String database, String
table) {
+ return resource(ResourceType.TABLE, database, table, null, null);
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CreatePolicyProcedure.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CreatePolicyProcedure.java
new file mode 100644
index 0000000000..52b56a55fa
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CreatePolicyProcedure.java
@@ -0,0 +1,100 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.spark.procedure;
+
+import org.apache.paimon.management.DataPolicy;
+import
org.apache.paimon.management.PolicyManagement.PolicyAlreadyExistException;
+import org.apache.paimon.management.PolicyType;
+
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+
+import static org.apache.spark.sql.types.DataTypes.StringType;
+
+/** Creates a table row-filter or column-mask policy. */
+public class CreatePolicyProcedure extends BasePolicyProcedure {
+
+ private static final ProcedureParameter[] PARAMETERS =
+ new ProcedureParameter[] {
+ ProcedureParameter.required("database", StringType),
+ ProcedureParameter.required("table", StringType),
+ ProcedureParameter.required("policy_type", StringType),
+ ProcedureParameter.required("principal", StringType),
+ ProcedureParameter.optional("predicate_json", StringType),
+ ProcedureParameter.optional("on_column", StringType),
+ ProcedureParameter.optional("transform_json", StringType)
+ };
+
+ private static final StructType OUTPUT_TYPE =
+ new StructType(
+ new StructField[] {
+ new StructField("result", DataTypes.BooleanType,
false, Metadata.empty())
+ });
+
+ private CreatePolicyProcedure(TableCatalog tableCatalog) {
+ super(tableCatalog);
+ }
+
+ @Override
+ public ProcedureParameter[] parameters() {
+ return PARAMETERS;
+ }
+
+ @Override
+ public StructType outputType() {
+ return OUTPUT_TYPE;
+ }
+
+ @Override
+ public InternalRow[] call(InternalRow args) {
+ DataPolicy policy =
+ policy(
+ args.getString(0),
+ args.getString(1),
+ enumValue(args.getString(2), PolicyType.class,
PARAMETERS[2].name()),
+ args.getString(3),
+ args.isNullAt(4) ? null : args.getString(4),
+ args.isNullAt(5) ? null : args.getString(5),
+ args.isNullAt(6) ? null : args.getString(6));
+ try {
+ policyManagement().createPolicy(policy);
+ } catch (PolicyAlreadyExistException e) {
+ throw new RuntimeException(e);
+ }
+ return new InternalRow[] {newInternalRow(true)};
+ }
+
+ public static ProcedureBuilder builder() {
+ return new Builder<CreatePolicyProcedure>() {
+ @Override
+ protected CreatePolicyProcedure doBuild() {
+ return new CreatePolicyProcedure(tableCatalog());
+ }
+ };
+ }
+
+ @Override
+ public String description() {
+ return "CreatePolicyProcedure";
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/DropPolicyProcedure.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/DropPolicyProcedure.java
new file mode 100644
index 0000000000..71780d1a84
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/DropPolicyProcedure.java
@@ -0,0 +1,94 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.spark.procedure;
+
+import org.apache.paimon.management.PermissionResource;
+import org.apache.paimon.management.PolicyType;
+
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+
+import static org.apache.spark.sql.types.DataTypes.BooleanType;
+import static org.apache.spark.sql.types.DataTypes.StringType;
+
+/** Drops one principal's row-filter or column-masking policy. */
+public class DropPolicyProcedure extends BasePolicyProcedure {
+
+ private static final ProcedureParameter[] PARAMETERS =
+ new ProcedureParameter[] {
+ ProcedureParameter.required("database", StringType),
+ ProcedureParameter.required("table", StringType),
+ ProcedureParameter.required("policy_type", StringType),
+ ProcedureParameter.required("principal", StringType),
+ ProcedureParameter.optional("column", StringType),
+ ProcedureParameter.optional("if_exists", BooleanType)
+ };
+
+ private static final StructType OUTPUT_TYPE =
+ new StructType(
+ new StructField[] {
+ new StructField("result", DataTypes.BooleanType,
false, Metadata.empty())
+ });
+
+ private DropPolicyProcedure(TableCatalog tableCatalog) {
+ super(tableCatalog);
+ }
+
+ @Override
+ public ProcedureParameter[] parameters() {
+ return PARAMETERS;
+ }
+
+ @Override
+ public StructType outputType() {
+ return OUTPUT_TYPE;
+ }
+
+ @Override
+ public InternalRow[] call(InternalRow args) {
+ PermissionResource resource = tableResource(args.getString(0),
args.getString(1));
+ PolicyType type = enumValue(args.getString(2), PolicyType.class,
PARAMETERS[2].name());
+ policyManagement()
+ .dropPolicy(
+ resource,
+ type,
+ args.getString(3),
+ args.isNullAt(4) ? null : args.getString(4),
+ !args.isNullAt(5) && args.getBoolean(5));
+ return new InternalRow[] {newInternalRow(true)};
+ }
+
+ public static ProcedureBuilder builder() {
+ return new Builder<DropPolicyProcedure>() {
+ @Override
+ protected DropPolicyProcedure doBuild() {
+ return new DropPolicyProcedure(tableCatalog());
+ }
+ };
+ }
+
+ @Override
+ public String description() {
+ return "DropPolicyProcedure";
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/GrantPermissionProcedure.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/GrantPermissionProcedure.java
new file mode 100644
index 0000000000..14cbef3b7c
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/GrantPermissionProcedure.java
@@ -0,0 +1,139 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.spark.procedure;
+
+import org.apache.paimon.management.PermissionAssignment;
+import org.apache.paimon.management.PermissionColumns;
+import org.apache.paimon.management.ResourceType;
+
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.catalyst.util.ArrayData;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.apache.spark.unsafe.types.UTF8String;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.apache.spark.sql.types.DataTypes.StringType;
+import static org.apache.spark.sql.types.DataTypes.createArrayType;
+
+/** Grants a permission through a REST catalog. */
+public class GrantPermissionProcedure extends BasePermissionProcedure {
+
+ private static final ProcedureParameter[] PARAMETERS =
+ new ProcedureParameter[] {
+ ProcedureParameter.required("resource_type", StringType),
+ ProcedureParameter.required("access", StringType),
+ ProcedureParameter.required("principal", StringType),
+ ProcedureParameter.optional("database", StringType),
+ ProcedureParameter.optional("table", StringType),
+ ProcedureParameter.optional("function", StringType),
+ ProcedureParameter.optional("view", StringType),
+ ProcedureParameter.optional("expire_time", StringType),
+ ProcedureParameter.optional("column_names",
createArrayType(StringType)),
+ ProcedureParameter.optional("excluded_column_names",
createArrayType(StringType))
+ };
+
+ private static final StructType OUTPUT_TYPE =
+ new StructType(
+ new StructField[] {
+ new StructField(
+ "result",
+
org.apache.spark.sql.types.DataTypes.BooleanType,
+ false,
+ Metadata.empty())
+ });
+
+ private GrantPermissionProcedure(TableCatalog tableCatalog) {
+ super(tableCatalog);
+ }
+
+ @Override
+ public ProcedureParameter[] parameters() {
+ return PARAMETERS;
+ }
+
+ @Override
+ public StructType outputType() {
+ return OUTPUT_TYPE;
+ }
+
+ @Override
+ public InternalRow[] call(InternalRow args) {
+ ResourceType resourceType =
+ enumValue(args.getString(0), ResourceType.class,
PARAMETERS[0].name());
+ PermissionAssignment assignment =
+ assignment(
+ resourceType,
+ args.getString(1),
+ args.getString(2),
+ args.isNullAt(3) ? null : args.getString(3),
+ args.isNullAt(4) ? null : args.getString(4),
+ args.isNullAt(5) ? null : args.getString(5),
+ args.isNullAt(6) ? null : args.getString(6),
+ columns(args, 8, 9),
+ args.isNullAt(7) ? null : args.getString(7));
+
+ permissionManagement().grantPermission(assignment);
+ return new InternalRow[] {newInternalRow(true)};
+ }
+
+ @Nullable
+ private static PermissionColumns columns(
+ InternalRow args, int columnNamesPos, int excludedColumnNamesPos) {
+ List<String> columnNames = stringArray(args, columnNamesPos);
+ List<String> excludedColumnNames = stringArray(args,
excludedColumnNamesPos);
+ return columnNames == null && excludedColumnNames == null
+ ? null
+ : new PermissionColumns(columnNames, excludedColumnNames);
+ }
+
+ @Nullable
+ private static List<String> stringArray(InternalRow args, int position) {
+ if (args.isNullAt(position)) {
+ return null;
+ }
+ ArrayData array = args.getArray(position);
+ List<String> values = new ArrayList<>(array.numElements());
+ for (int i = 0; i < array.numElements(); i++) {
+ UTF8String value = array.isNullAt(i) ? null :
array.getUTF8String(i);
+ values.add(value == null ? null : value.toString());
+ }
+ return values;
+ }
+
+ public static ProcedureBuilder builder() {
+ return new Builder<GrantPermissionProcedure>() {
+ @Override
+ protected GrantPermissionProcedure doBuild() {
+ return new GrantPermissionProcedure(tableCatalog());
+ }
+ };
+ }
+
+ @Override
+ public String description() {
+ return "GrantPermissionProcedure";
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/ListPermissionsProcedure.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/ListPermissionsProcedure.java
new file mode 100644
index 0000000000..70d256e5c9
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/ListPermissionsProcedure.java
@@ -0,0 +1,160 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.spark.procedure;
+
+import org.apache.paimon.PagedList;
+import org.apache.paimon.management.ListPermissionsRequest;
+import org.apache.paimon.management.PermissionAssignment;
+import org.apache.paimon.management.PermissionColumns;
+import org.apache.paimon.management.ResourceType;
+
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.catalyst.util.GenericArrayData;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.apache.spark.unsafe.types.UTF8String;
+
+import java.util.List;
+
+import static org.apache.spark.sql.types.DataTypes.IntegerType;
+import static org.apache.spark.sql.types.DataTypes.StringType;
+import static org.apache.spark.sql.types.DataTypes.createArrayType;
+
+/** Lists direct permissions on an exact target. */
+public class ListPermissionsProcedure extends BasePermissionProcedure {
+
+ private static final ProcedureParameter[] PARAMETERS =
+ new ProcedureParameter[] {
+ ProcedureParameter.required("resource_type", StringType),
+ ProcedureParameter.optional("database", StringType),
+ ProcedureParameter.optional("table", StringType),
+ ProcedureParameter.optional("function", StringType),
+ ProcedureParameter.optional("view", StringType),
+ ProcedureParameter.optional("principal", StringType),
+ ProcedureParameter.optional("access", StringType),
+ ProcedureParameter.optional("max_results", IntegerType),
+ ProcedureParameter.optional("page_token", StringType)
+ };
+
+ private static final StructType OUTPUT_TYPE =
+ new StructType(
+ new StructField[] {
+ field("resource_type", StringType, false),
+ field("database", StringType, true),
+ field("table", StringType, true),
+ field("function", StringType, true),
+ field("view", StringType, true),
+ field("access", StringType, false),
+ field("principal", StringType, false),
+ field("column_names", createArrayType(StringType),
true),
+ field("excluded_column_names",
createArrayType(StringType), true),
+ field("expire_time", StringType, true),
+ field("next_page_token", StringType, true)
+ });
+
+ private ListPermissionsProcedure(TableCatalog tableCatalog) {
+ super(tableCatalog);
+ }
+
+ @Override
+ public ProcedureParameter[] parameters() {
+ return PARAMETERS;
+ }
+
+ @Override
+ public StructType outputType() {
+ return OUTPUT_TYPE;
+ }
+
+ @Override
+ public InternalRow[] call(InternalRow args) {
+ ResourceType resourceType =
+ enumValue(args.getString(0), ResourceType.class,
PARAMETERS[0].name());
+ Integer maxResults = args.isNullAt(7) ? null : args.getInt(7);
+ ListPermissionsRequest request =
+ new ListPermissionsRequest(
+ resourceType,
+ args.isNullAt(1) ? null :
emptyToNull(args.getString(1)),
+ args.isNullAt(2) ? null :
emptyToNull(args.getString(2)),
+ args.isNullAt(3) ? null :
emptyToNull(args.getString(3)),
+ args.isNullAt(4) ? null :
emptyToNull(args.getString(4)),
+ args.isNullAt(5) ? null :
emptyToNull(args.getString(5)),
+ args.isNullAt(6) ? null :
emptyToNull(args.getString(6)),
+ args.isNullAt(8) ? null :
emptyToNull(args.getString(8)),
+ maxResults);
+ PagedList<PermissionAssignment> page =
permissionManagement().listPermissions(request);
+ List<PermissionAssignment> assignments = page.getElements();
+ if (assignments == null || assignments.isEmpty()) {
+ return new InternalRow[0];
+ }
+
+ InternalRow[] rows = new InternalRow[assignments.size()];
+ for (int i = 0; i < assignments.size(); i++) {
+ PermissionAssignment assignment = assignments.get(i);
+ PermissionColumns columns = assignment.getColumns();
+ rows[i] =
+ newInternalRow(
+ string(assignment.getResource().getType().name()),
+ string(assignment.getResource().getDatabase()),
+ string(assignment.getResource().getTable()),
+ string(assignment.getResource().getFunction()),
+ string(assignment.getResource().getView()),
+ string(assignment.getAccess()),
+ string(assignment.getPrincipal()),
+ stringArray(columns == null ? null :
columns.getColumnNames()),
+ stringArray(columns == null ? null :
columns.getExcludedColumnNames()),
+ string(assignment.getExpireTime()),
+ string(page.getNextPageToken()));
+ }
+ return rows;
+ }
+
+ private static StructField field(
+ String name, org.apache.spark.sql.types.DataType type, boolean
nullable) {
+ return new StructField(name, type, nullable, Metadata.empty());
+ }
+
+ private static UTF8String string(String value) {
+ return value == null ? null : UTF8String.fromString(value);
+ }
+
+ private static GenericArrayData stringArray(List<String> values) {
+ if (values == null) {
+ return null;
+ }
+ return new GenericArrayData(
+
values.stream().map(ListPermissionsProcedure::string).toArray());
+ }
+
+ public static ProcedureBuilder builder() {
+ return new Builder<ListPermissionsProcedure>() {
+ @Override
+ protected ListPermissionsProcedure doBuild() {
+ return new ListPermissionsProcedure(tableCatalog());
+ }
+ };
+ }
+
+ @Override
+ public String description() {
+ return "ListPermissionsProcedure";
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/ListPoliciesProcedure.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/ListPoliciesProcedure.java
new file mode 100644
index 0000000000..fa4d8a244f
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/ListPoliciesProcedure.java
@@ -0,0 +1,140 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.spark.procedure;
+
+import org.apache.paimon.PagedList;
+import org.apache.paimon.management.ColumnMask;
+import org.apache.paimon.management.DataPolicy;
+import org.apache.paimon.management.ListPoliciesRequest;
+import org.apache.paimon.management.PolicyType;
+import org.apache.paimon.management.RowFilter;
+
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.apache.spark.unsafe.types.UTF8String;
+
+import java.util.List;
+
+import static org.apache.spark.sql.types.DataTypes.IntegerType;
+import static org.apache.spark.sql.types.DataTypes.StringType;
+
+/** Lists policies attached to an exact table. */
+public class ListPoliciesProcedure extends BasePolicyProcedure {
+
+ private static final ProcedureParameter[] PARAMETERS =
+ new ProcedureParameter[] {
+ ProcedureParameter.required("database", StringType),
+ ProcedureParameter.required("table", StringType),
+ ProcedureParameter.optional("policy_type", StringType),
+ ProcedureParameter.optional("principal", StringType),
+ ProcedureParameter.optional("column", StringType),
+ ProcedureParameter.optional("max_results", IntegerType),
+ ProcedureParameter.optional("page_token", StringType)
+ };
+
+ private static final StructType OUTPUT_TYPE =
+ new StructType(
+ new StructField[] {
+ field("database", StringType, false),
+ field("table", StringType, false),
+ field("policy_type", StringType, false),
+ field("principal", StringType, false),
+ field("predicate_json", StringType, true),
+ field("on_column", StringType, true),
+ field("transform_json", StringType, true),
+ field("next_page_token", StringType, true)
+ });
+
+ private ListPoliciesProcedure(TableCatalog tableCatalog) {
+ super(tableCatalog);
+ }
+
+ @Override
+ public ProcedureParameter[] parameters() {
+ return PARAMETERS;
+ }
+
+ @Override
+ public StructType outputType() {
+ return OUTPUT_TYPE;
+ }
+
+ @Override
+ public InternalRow[] call(InternalRow args) {
+ ListPoliciesRequest request =
+ new ListPoliciesRequest(
+ tableResource(args.getString(0), args.getString(1)),
+ optionalEnum(
+ args.isNullAt(2) ? null : args.getString(2),
+ PolicyType.class,
+ PARAMETERS[2].name()),
+ args.isNullAt(3) ? null : args.getString(3),
+ args.isNullAt(4) ? null : args.getString(4),
+ args.isNullAt(6) ? null : args.getString(6),
+ args.isNullAt(5) ? null : args.getInt(5));
+ PagedList<DataPolicy> page = policyManagement().listPolicies(request);
+ List<DataPolicy> policies = page.getElements();
+ if (policies == null || policies.isEmpty()) {
+ return new InternalRow[0];
+ }
+ InternalRow[] rows = new InternalRow[policies.size()];
+ for (int i = 0; i < policies.size(); i++) {
+ DataPolicy policy = policies.get(i);
+ RowFilter rowFilter = policy.getRowFilter();
+ ColumnMask columnMask = policy.getColumnMask();
+ rows[i] =
+ newInternalRow(
+ string(policy.getResource().getDatabase()),
+ string(policy.getResource().getTable()),
+ string(policy.type().name()),
+ string(policy.getPrincipal()),
+ string(rowFilter == null ? null :
rowFilter.getPredicate()),
+ string(columnMask == null ? null :
columnMask.getOnColumn()),
+ string(columnMask == null ? null :
columnMask.getTransform()),
+ string(page.getNextPageToken()));
+ }
+ return rows;
+ }
+
+ private static StructField field(
+ String name, org.apache.spark.sql.types.DataType type, boolean
nullable) {
+ return new StructField(name, type, nullable, Metadata.empty());
+ }
+
+ private static UTF8String string(String value) {
+ return value == null ? null : UTF8String.fromString(value);
+ }
+
+ public static ProcedureBuilder builder() {
+ return new Builder<ListPoliciesProcedure>() {
+ @Override
+ protected ListPoliciesProcedure doBuild() {
+ return new ListPoliciesProcedure(tableCatalog());
+ }
+ };
+ }
+
+ @Override
+ public String description() {
+ return "ListPoliciesProcedure";
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RevokePermissionProcedure.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RevokePermissionProcedure.java
new file mode 100644
index 0000000000..516c8d1ae1
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RevokePermissionProcedure.java
@@ -0,0 +1,96 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.spark.procedure;
+
+import org.apache.paimon.management.PermissionResource;
+import org.apache.paimon.management.ResourceType;
+
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+
+import static org.apache.spark.sql.types.DataTypes.StringType;
+
+/** Revokes a permission by its resource identity. */
+public class RevokePermissionProcedure extends BasePermissionProcedure {
+
+ private static final ProcedureParameter[] PARAMETERS =
+ new ProcedureParameter[] {
+ ProcedureParameter.required("resource_type", StringType),
+ ProcedureParameter.required("access", StringType),
+ ProcedureParameter.required("principal", StringType),
+ ProcedureParameter.optional("database", StringType),
+ ProcedureParameter.optional("table", StringType),
+ ProcedureParameter.optional("function", StringType),
+ ProcedureParameter.optional("view", StringType)
+ };
+
+ private static final StructType OUTPUT_TYPE =
+ new StructType(
+ new StructField[] {
+ new StructField("result", DataTypes.BooleanType,
false, Metadata.empty())
+ });
+
+ private RevokePermissionProcedure(TableCatalog tableCatalog) {
+ super(tableCatalog);
+ }
+
+ @Override
+ public ProcedureParameter[] parameters() {
+ return PARAMETERS;
+ }
+
+ @Override
+ public StructType outputType() {
+ return OUTPUT_TYPE;
+ }
+
+ @Override
+ public InternalRow[] call(InternalRow args) {
+ ResourceType resourceType =
+ enumValue(args.getString(0), ResourceType.class,
PARAMETERS[0].name());
+ PermissionResource resource =
+ resource(
+ resourceType,
+ args.isNullAt(3) ? null : args.getString(3),
+ args.isNullAt(4) ? null : args.getString(4),
+ args.isNullAt(5) ? null : args.getString(5),
+ args.isNullAt(6) ? null : args.getString(6));
+
+ permissionManagement().revokePermission(resource, args.getString(1),
args.getString(2));
+ return new InternalRow[] {newInternalRow(true)};
+ }
+
+ public static ProcedureBuilder builder() {
+ return new Builder<RevokePermissionProcedure>() {
+ @Override
+ protected RevokePermissionProcedure doBuild() {
+ return new RevokePermissionProcedure(tableCatalog());
+ }
+ };
+ }
+
+ @Override
+ public String description() {
+ return "RevokePermissionProcedure";
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/AbstractPaimonSparkSqlExtensionsParser.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/AbstractPaimonSparkSqlExtensionsParser.scala
index 7770143c80..6cede203d4 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/AbstractPaimonSparkSqlExtensionsParser.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/AbstractPaimonSparkSqlExtensionsParser.scala
@@ -71,6 +71,7 @@ abstract class AbstractPaimonSparkSqlExtensionsParser(val
delegate: ParserInterf
PaimonSqlExtensionsParser.TAG,
PaimonSqlExtensionsParser.TRUE,
PaimonSqlExtensionsParser.FALSE,
+ PaimonSqlExtensionsParser.ARRAY,
PaimonSqlExtensionsParser.MAP,
PaimonSqlExtensionsParser.COPY,
PaimonSqlExtensionsParser.INTO,
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSparkTestWithRestCatalogBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSparkTestWithRestCatalogBase.scala
index b8b8d53e21..12b5c8febe 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSparkTestWithRestCatalogBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSparkTestWithRestCatalogBase.scala
@@ -30,7 +30,7 @@ import java.util.UUID
class PaimonSparkTestWithRestCatalogBase extends PaimonSparkTestBase {
- private var restCatalogServer: RESTCatalogServer = _
+ protected var restCatalogServer: RESTCatalogServer = _
private var serverUrl: String = _
protected var warehouse: String = _
private val initToken = "init_token"
@@ -43,13 +43,25 @@ class PaimonSparkTestWithRestCatalogBase extends
PaimonSparkTestBase {
"paimon",
CatalogOptions.WAREHOUSE.key,
warehouse),
- ImmutableMap.of())
+ ImmutableMap.of()
+ )
val authProvider = new BearTokenAuthProvider(initToken)
restCatalogServer =
new RESTCatalogServer(tempDBDir.getCanonicalPath, authProvider, config,
warehouse)
restCatalogServer.start()
serverUrl = restCatalogServer.getUrl
super.beforeAll()
+ Seq("analyst", "first", "second", "reader", "function_reader").foreach(
+ restCatalogServer.registerManagementPrincipal)
+ restCatalogServer.registerManagementPrincipal("analysts")
+ restCatalogServer.registerManagementPrincipal("admin")
+ spark.sql("CREATE DATABASE IF NOT EXISTS paimon.sales")
+ spark.sql("""CREATE TABLE IF NOT EXISTS paimon.sales.orders (
+ | id INT,
+ | region STRING,
+ | email STRING)
+ |TBLPROPERTIES ('query-auth.enabled' = 'true')
+ |""".stripMargin)
}
override protected def afterAll(): Unit = {
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/PermissionProcedureTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/PermissionProcedureTest.scala
new file mode 100644
index 0000000000..3e34fe56f2
--- /dev/null
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/PermissionProcedureTest.scala
@@ -0,0 +1,787 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.spark.procedure
+
+import org.apache.paimon.catalog.Identifier
+import org.apache.paimon.data.BinaryString
+import org.apache.paimon.management.{PermissionResource, ResourceType}
+import org.apache.paimon.predicate.{ConcatTransform, Equal, FieldRef,
FieldTransform, LeafPredicate}
+import org.apache.paimon.spark.{PaimonSparkTestBase,
PaimonSparkTestWithRestCatalogBase}
+import org.apache.paimon.types.DataTypes
+import org.apache.paimon.utils.JsonSerdeUtil
+
+import org.apache.spark.sql.Row
+import org.assertj.core.api.Assertions.assertThat
+
+import java.util.{Arrays, Collections}
+
+/** End-to-end tests for permission and policy management procedures. */
+class PermissionProcedureTest extends PaimonSparkTestWithRestCatalogBase {
+
+ test("grant, list and idempotently revoke a permission") {
+ checkAnswer(
+ spark.sql("""CALL sys.grant_permission(
+ | resource_type => 'table',
+ | access => 'select',
+ | principal => 'analyst',
+ | database => 'sales',
+ | table => 'orders',
+ | expire_time => '2027-01-01T00:00:00Z')
+ |""".stripMargin),
+ Row(true)
+ )
+
+ checkAnswer(
+ spark.sql("""CALL sys.grant_permission(
+ | resource_type => 'TABLE',
+ | access => 'SELECT',
+ | principal => 'analyst',
+ | database => 'sales',
+ | table => 'orders',
+ | expire_time => '2028-01-01T00:00:00Z')
+ |""".stripMargin),
+ Row(true)
+ )
+
+ val listed = spark.sql("""CALL sys.list_permissions(
+ | resource_type => 'TABLE',
+ | database => 'sales',
+ | table => 'orders',
+ | principal => 'analyst')
+ |""".stripMargin)
+ assertThat(listed.columns).containsExactly(
+ "resource_type",
+ "database",
+ "table",
+ "function",
+ "view",
+ "access",
+ "principal",
+ "column_names",
+ "excluded_column_names",
+ "expire_time",
+ "next_page_token")
+ val assignments = listed.collect()
+ assertThat(assignments).hasSize(1)
+ val assignment = assignments.head
+ assertThat(assignment.getString(0)).isEqualTo("TABLE")
+ assertThat(assignment.getString(1)).isEqualTo("sales")
+ assertThat(assignment.getString(2)).isEqualTo("orders")
+ assertThat(assignment.getString(5)).isEqualTo("SELECT")
+ assertThat(assignment.getString(6)).isEqualTo("analyst")
+ assertThat(assignment.isNullAt(7)).isTrue
+ assertThat(assignment.isNullAt(8)).isTrue
+ assertThat(assignment.getString(9)).isEqualTo("2028-01-01T00:00:00Z")
+ assertThat(assignment.isNullAt(10)).isTrue
+
+ val revoke = """CALL sys.revoke_permission(
+ | resource_type => 'TABLE',
+ | access => 'SELECT',
+ | principal => 'analyst',
+ | database => 'sales',
+ | table => 'orders')
+ |""".stripMargin
+ checkAnswer(spark.sql(revoke), Row(true))
+ checkAnswer(spark.sql(revoke), Row(true))
+ checkAnswer(
+ spark.sql("""CALL sys.list_permissions(
+ | resource_type => 'TABLE',
+ | database => 'sales',
+ | table => 'orders',
+ | principal => 'analyst')
+ |""".stripMargin),
+ Nil
+ )
+ }
+
+ test("list permissions supports opaque pagination tokens") {
+ grantCatalogPermission("first")
+ grantCatalogPermission("second")
+
+ val first = spark
+ .sql("CALL sys.list_permissions(resource_type => 'CATALOG', max_results
=> 1)")
+ .head()
+ assertThat(first.getString(6)).isEqualTo("first")
+ val pageToken = first.getString(10)
+ assertThat(pageToken).isNotEmpty.isNotEqualTo("1")
+
+ spark.sql(
+ "CALL sys.revoke_permission(resource_type => 'CATALOG', " +
+ "access => 'CREATEDATABASE', principal => 'first')")
+
+ val second = spark
+ .sql(
+ "CALL sys.list_permissions(resource_type => 'CATALOG', max_results =>
1, " +
+ s"page_token => ${sqlLiteral(pageToken)})"
+ )
+ .head()
+ assertThat(second.getString(6)).isEqualTo("second")
+ assertThat(second.isNullAt(10)).isTrue
+ }
+
+ test("grant and list explicit descendant scopes") {
+ checkAnswer(
+ spark.sql("""CALL sys.grant_permission(
+ | resource_type => 'CATALOG_ALL',
+ | access => 'SELECT',
+ | principal => 'analyst')
+ |""".stripMargin),
+ Row(true)
+ )
+ val catalogAll = spark
+ .sql(
+ "CALL sys.list_permissions(resource_type => 'CATALOG_ALL', principal
=> 'analyst')"
+ )
+ .head()
+ assertThat(catalogAll.getString(0)).isEqualTo("CATALOG_ALL")
+ assertThat(catalogAll.isNullAt(1)).isTrue
+ assertThat(catalogAll.getString(5)).isEqualTo("SELECT")
+
+ checkAnswer(
+ spark.sql("""CALL sys.grant_permission(
+ | resource_type => 'DATABASE_ALL',
+ | database => 'sales',
+ | access => 'UPDATE',
+ | principal => 'analyst')
+ |""".stripMargin),
+ Row(true)
+ )
+ val databaseAll = spark
+ .sql("""CALL sys.list_permissions(
+ | resource_type => 'DATABASE_ALL',
+ | database => 'sales',
+ | principal => 'analyst')
+ |""".stripMargin)
+ .head()
+ assertThat(databaseAll.getString(0)).isEqualTo("DATABASE_ALL")
+ assertThat(databaseAll.getString(1)).isEqualTo("sales")
+ assertThat(databaseAll.isNullAt(2)).isTrue
+ assertThat(databaseAll.getString(5)).isEqualTo("UPDATE")
+ }
+
+ test("grant, replace, list and enforce column permissions") {
+ restCatalogServer.setQueryPrincipals(Collections.singleton("analyst"))
+
+ checkAnswer(
+ spark.sql("""CALL sys.grant_permission(
+ | resource_type => 'COLUMN',
+ | access => 'SELECT',
+ | principal => 'analyst',
+ | database => 'sales',
+ | table => 'orders',
+ | column_names => array('id', 'region'))
+ |""".stripMargin),
+ Row(true)
+ )
+
+ val included = spark.sql("""CALL sys.list_permissions(
+ | resource_type => 'COLUMN',
+ | database => 'sales',
+ | table => 'orders',
+ | principal => 'analyst')
+ |""".stripMargin)
+ assertThat(included.columns).containsExactly(
+ "resource_type",
+ "database",
+ "table",
+ "function",
+ "view",
+ "access",
+ "principal",
+ "column_names",
+ "excluded_column_names",
+ "expire_time",
+ "next_page_token")
+ assertThat(included.head().getSeq[String](7)).isEqualTo(Seq("id",
"region"))
+ assertThat(included.head().isNullAt(8)).isTrue
+
+ checkAnswer(spark.sql("SELECT id, region FROM paimon.sales.orders"), Nil)
+ val deniedEmail = intercept[Exception] {
+ spark.sql("SELECT email FROM paimon.sales.orders").collect()
+ }
+ assertThat(deniedEmail.getMessage).contains("permission")
+
+ checkAnswer(
+ spark.sql("""CALL sys.grant_permission(
+ | resource_type => 'COLUMN',
+ | access => 'SELECT',
+ | principal => 'analyst',
+ | database => 'sales',
+ | table => 'orders',
+ | excluded_column_names => array('region'))
+ |""".stripMargin),
+ Row(true)
+ )
+ checkAnswer(spark.sql("SELECT id, email FROM paimon.sales.orders"), Nil)
+ val deniedRegion = intercept[Exception] {
+ spark.sql("SELECT region FROM paimon.sales.orders").collect()
+ }
+ assertThat(deniedRegion.getMessage).contains("permission")
+
+ restCatalogServer.registerManagementPrincipal("limited")
+ restCatalogServer.setQueryPrincipals(new
java.util.HashSet(Arrays.asList("analyst", "limited")))
+ checkAnswer(
+ spark.sql("""CALL sys.grant_permission(
+ | resource_type => 'COLUMN', access => 'SELECT',
+ | principal => 'limited', database => 'sales', table =>
'orders',
+ | column_names => array('id', 'region'))
+ |""".stripMargin),
+ Row(true)
+ )
+ checkAnswer(spark.sql("SELECT id FROM paimon.sales.orders"), Nil)
+ val deniedByIntersection = intercept[Exception] {
+ spark.sql("SELECT email FROM paimon.sales.orders").collect()
+ }
+ assertThat(deniedByIntersection.getMessage).contains("permission")
+
+ checkAnswer(
+ spark.sql("""CALL sys.revoke_permission(
+ | resource_type => 'COLUMN', access => 'SELECT',
+ | principal => 'analyst', database => 'sales', table =>
'orders')
+ |""".stripMargin),
+ Row(true)
+ )
+ }
+
+ test("column permission validates query authorization and referenced
columns") {
+ spark.sql("CREATE TABLE paimon.sales.disabled_columns (id INT)")
+ val disabled = intercept[Exception] {
+ spark
+ .sql("""CALL sys.grant_permission(
+ | resource_type => 'COLUMN', access => 'SELECT',
+ | principal => 'analyst', database => 'sales', table =>
'disabled_columns',
+ | column_names => array('id'))
+ |""".stripMargin)
+ .collect()
+ }
+ assertThat(disabled.getMessage).contains("query-auth.enabled=true")
+
+ val missing = intercept[Exception] {
+ spark
+ .sql("""CALL sys.grant_permission(
+ | resource_type => 'COLUMN', access => 'SELECT',
+ | principal => 'analyst', database => 'sales', table =>
'orders',
+ | excluded_column_names => array('missing'))
+ |""".stripMargin)
+ .collect()
+ }
+ assertThat(missing.getMessage).contains("Permission column does not exist")
+ }
+
+ test("column permissions follow table and schema lifecycle") {
+ restCatalogServer.setQueryPrincipals(Collections.singleton("analyst"))
+ spark.sql("""CREATE TABLE paimon.sales.column_lifecycle (
+ | id INT,
+ | secret STRING)
+ |TBLPROPERTIES ('query-auth.enabled' = 'true')
+ |""".stripMargin)
+ spark.sql("INSERT INTO paimon.sales.column_lifecycle VALUES (1, 's1')")
+ checkAnswer(
+ spark.sql("""CALL sys.grant_permission(
+ | resource_type => 'COLUMN', access => 'SELECT', principal
=> 'analyst',
+ | database => 'sales', table => 'column_lifecycle',
+ | excluded_column_names => array('secret'))
+ |""".stripMargin),
+ Row(true)
+ )
+
+ spark.sql(
+ "ALTER TABLE paimon.sales.column_lifecycle RENAME TO
paimon.sales.renamed_column_lifecycle")
+ var assignment = spark
+ .sql(
+ "CALL sys.list_permissions(resource_type => 'COLUMN', database =>
'sales', " +
+ "table => 'renamed_column_lifecycle', principal => 'analyst')")
+ .head()
+ assertThat(assignment.getString(2)).isEqualTo("renamed_column_lifecycle")
+ assertThat(assignment.getSeq[String](8)).isEqualTo(Seq("secret"))
+
+ val disableAuth = intercept[Exception] {
+ spark
+ .sql("ALTER TABLE paimon.sales.renamed_column_lifecycle " +
+ "SET TBLPROPERTIES ('query-auth.enabled' = 'false')")
+ .collect()
+ }
+ assertThat(disableAuth.getMessage).contains("Cannot disable
query-auth.enabled")
+
+ spark.sql(
+ "ALTER TABLE paimon.sales.renamed_column_lifecycle " +
+ "RENAME COLUMN secret TO private_secret")
+ assignment = spark
+ .sql(
+ "CALL sys.list_permissions(resource_type => 'COLUMN', database =>
'sales', " +
+ "table => 'renamed_column_lifecycle', principal => 'analyst')")
+ .head()
+ assertThat(assignment.getSeq[String](8)).isEqualTo(Seq("private_secret"))
+ val denied = intercept[Exception] {
+ spark.sql("SELECT private_secret FROM
paimon.sales.renamed_column_lifecycle").collect()
+ }
+ assertThat(denied.getMessage).contains("permission")
+
+ spark.sql("""CALL sys.grant_permission(
+ | resource_type => 'COLUMN', access => 'SELECT', principal =>
'analyst',
+ | database => 'sales', table => 'renamed_column_lifecycle',
+ | column_names => array('private_secret'))
+ |""".stripMargin)
+ val dropOnlyAllowedColumn = intercept[Exception] {
+ spark
+ .sql("ALTER TABLE paimon.sales.renamed_column_lifecycle DROP COLUMN
private_secret")
+ .collect()
+ }
+ assertThat(dropOnlyAllowedColumn.getMessage).contains("Cannot drop every
allowed column")
+
+ spark.sql("""CALL sys.grant_permission(
+ | resource_type => 'COLUMN', access => 'SELECT', principal =>
'analyst',
+ | database => 'sales', table => 'renamed_column_lifecycle',
+ | excluded_column_names => array('private_secret'))
+ |""".stripMargin)
+ spark.sql("ALTER TABLE paimon.sales.renamed_column_lifecycle DROP COLUMN
private_secret")
+ checkAnswer(
+ spark.sql(
+ "CALL sys.list_permissions(resource_type => 'COLUMN', database =>
'sales', " +
+ "table => 'renamed_column_lifecycle', principal => 'analyst')"),
+ Nil
+ )
+
+ grantTablePermission("renamed_column_lifecycle", "analyst")
+ spark.sql("DROP TABLE paimon.sales.renamed_column_lifecycle")
+ spark.sql("CREATE TABLE paimon.sales.renamed_column_lifecycle (id INT)")
+ checkAnswer(
+ spark.sql(
+ "CALL sys.list_permissions(resource_type => 'TABLE', database =>
'sales', " +
+ "table => 'renamed_column_lifecycle', principal => 'analyst')"),
+ Nil
+ )
+ }
+
+ test("create, reject duplicates, apply and idempotently drop table data
policies") {
+ restCatalogServer.setQueryPrincipals(Collections.singleton("analyst"))
+ spark.sql(
+ "INSERT OVERWRITE paimon.sales.orders VALUES " +
+ "(1, 'APAC', '[email protected]'), (2, 'EMEA', '[email protected]')")
+
+ val apacFilter = stringEqualsPredicate(1, "region", "APAC")
+ val emeaFilter = stringEqualsPredicate(1, "region", "EMEA")
+ val emailMask = concatFieldTransform(1, "region", "-masked")
+
+ checkAnswer(
+ spark.sql(s"""CALL sys.create_policy(
+ | database => 'sales',
+ | table => 'orders',
+ | policy_type => 'ROW_FILTER',
+ | principal => 'analyst',
+ | predicate_json => ${sqlLiteral(apacFilter)})
+ |""".stripMargin),
+ Row(true)
+ )
+
+ val listed = spark.sql("""CALL sys.list_policies(
+ | database => 'sales',
+ | table => 'orders',
+ | policy_type => 'ROW_FILTER',
+ | principal => 'analyst')
+ |""".stripMargin)
+ assertThat(listed.columns).containsExactly(
+ "database",
+ "table",
+ "policy_type",
+ "principal",
+ "predicate_json",
+ "on_column",
+ "transform_json",
+ "next_page_token")
+ val direct = listed.head()
+ assertThat(direct.getString(2)).isEqualTo("ROW_FILTER")
+ assertThat(direct.getString(3)).isEqualTo("analyst")
+ assertThat(direct.getString(4)).contains("\"name\":\"region\"")
+ assertThat(direct.isNullAt(5)).isTrue
+ assertThat(direct.isNullAt(6)).isTrue
+ checkAnswer(
+ spark.sql("SELECT id, region, email FROM paimon.sales.orders ORDER BY
id"),
+ Row(1, "APAC", "[email protected]")
+ )
+
+ val duplicate = intercept[Exception] {
+ spark
+ .sql(s"""CALL sys.create_policy(
+ | database => 'sales',
+ | table => 'orders',
+ | policy_type => 'ROW_FILTER',
+ | principal => 'analyst',
+ | predicate_json => ${sqlLiteral(emeaFilter)})
+ |""".stripMargin)
+ .collect()
+ }
+ assertThat(duplicate.getMessage).contains("already exists")
+ val unchanged = spark
+ .sql("""CALL sys.list_policies(
+ | database => 'sales',
+ | table => 'orders',
+ | policy_type => 'ROW_FILTER',
+ | principal => 'analyst')
+ |""".stripMargin)
+ .head()
+ assertThat(unchanged.getString(4)).contains("APAC").doesNotContain("EMEA")
+
+ checkAnswer(
+ spark.sql(s"""CALL sys.create_policy(
+ | database => 'sales',
+ | table => 'orders',
+ | policy_type => 'COLUMN_MASKING',
+ | principal => 'analyst',
+ | on_column => 'email',
+ | transform_json => ${sqlLiteral(emailMask)})
+ |""".stripMargin),
+ Row(true)
+ )
+
+ val mask = spark
+ .sql("""CALL sys.list_policies(
+ | database => 'sales',
+ | table => 'orders',
+ | policy_type => 'COLUMN_MASKING')
+ |""".stripMargin)
+ .head()
+ assertThat(mask.getString(2)).isEqualTo("COLUMN_MASKING")
+ assertThat(mask.getString(3)).isEqualTo("analyst")
+ assertThat(mask.isNullAt(4)).isTrue
+ assertThat(mask.getString(5)).isEqualTo("email")
+ assertThat(mask.getString(6)).contains("-masked")
+
+ checkAnswer(
+ spark.sql("SELECT id, region, email FROM paimon.sales.orders ORDER BY
id"),
+ Row(1, "APAC", "APAC-masked")
+ )
+ checkAnswer(
+ spark.sql("SELECT email FROM paimon.sales.orders"),
+ Row("APAC-masked")
+ )
+ checkAnswer(
+ spark.sql("""CALL sys.drop_policy(
+ | database => 'sales',
+ | table => 'orders',
+ | policy_type => 'ROW_FILTER',
+ | principal => 'analyst',
+ | if_exists => true)
+ |""".stripMargin),
+ Row(true)
+ )
+ checkAnswer(
+ spark.sql("""CALL sys.drop_policy(
+ | database => 'sales',
+ | table => 'orders',
+ | policy_type => 'ROW_FILTER',
+ | principal => 'analyst',
+ | if_exists => true)
+ |""".stripMargin),
+ Row(true)
+ )
+ checkAnswer(
+ spark.sql("""CALL sys.list_policies(
+ | database => 'sales',
+ | table => 'orders',
+ | policy_type => 'ROW_FILTER',
+ | principal => 'analyst')
+ |""".stripMargin),
+ Nil
+ )
+ spark.sql(
+ "CALL sys.drop_policy(database => 'sales', table => 'orders', " +
+ "policy_type => 'COLUMN_MASKING', " +
+ "principal => 'analyst', column => 'email', if_exists => true)")
+ }
+
+ test("policy creation validates query authorization, JSON and columns") {
+ val validFilter = intEqualsPredicate(0, "id", 1)
+ spark.sql("CREATE TABLE paimon.sales.disabled_orders (id INT)")
+
+ val disabled = intercept[Exception] {
+ spark
+ .sql(s"""CALL sys.create_policy(
+ | database => 'sales', table => 'disabled_orders',
+ | policy_type => 'ROW_FILTER', predicate_json =>
${sqlLiteral(validFilter)},
+ | principal => 'analyst')
+ |""".stripMargin)
+ .collect()
+ }
+ assertThat(disabled.getMessage).contains("query-auth.enabled=true")
+
+ val malformed = intercept[Exception] {
+ spark
+ .sql("""CALL sys.create_policy(
+ | database => 'sales', table => 'orders',
+ | policy_type => 'ROW_FILTER', predicate_json => '{bad',
+ | principal => 'analyst')
+ |""".stripMargin)
+ .collect()
+ }
+ assertThat(malformed.getMessage).contains("Unexpected character")
+
+ val unknownColumnFilter = stringEqualsPredicate(0, "unknown", "APAC")
+ val missingColumn = intercept[Exception] {
+ spark
+ .sql(s"""CALL sys.create_policy(
+ | database => 'sales', table => 'orders',
+ | policy_type => 'ROW_FILTER', predicate_json =>
${sqlLiteral(unknownColumnFilter)},
+ | principal => 'analyst')
+ |""".stripMargin)
+ .collect()
+ }
+ assertThat(missingColumn.getMessage).contains("column unknown")
+
+ val missingPrincipal = intercept[Exception] {
+ spark
+ .sql(s"""CALL sys.create_policy(
+ | database => 'sales', table => 'orders',
+ | policy_type => 'ROW_FILTER', predicate_json =>
${sqlLiteral(validFilter)},
+ | principal => 'missing')
+ |""".stripMargin)
+ .collect()
+ }
+ assertThat(missingPrincipal.getMessage).contains("principal does not
exist")
+
+ val jsonNull = intercept[Exception] {
+ spark
+ .sql("""CALL sys.create_policy(
+ | database => 'sales', table => 'orders',
+ | policy_type => 'ROW_FILTER', predicate_json => 'null',
+ | principal => 'analyst')
+ |""".stripMargin)
+ .collect()
+ }
+ assertThat(jsonNull.getMessage).contains("JSON null")
+
+ val mixedDefinition = intercept[Exception] {
+ spark
+ .sql(s"""CALL sys.create_policy(
+ | database => 'sales', table => 'orders',
+ | policy_type => 'ROW_FILTER', predicate_json =>
${sqlLiteral(validFilter)},
+ | transform_json =>
${sqlLiteral(constantStringTransform("****"))},
+ | principal => 'analyst')
+ |""".stripMargin)
+ .collect()
+ }
+ assertThat(mixedDefinition.getMessage).contains("cannot specify transform")
+ }
+
+ test("list policies supports opaque pagination tokens") {
+ val filter = intEqualsPredicate(0, "id", 1)
+ Seq("first", "second").foreach {
+ principal =>
+ checkAnswer(
+ spark.sql(s"""CALL sys.create_policy(
+ | database => 'sales', table => 'orders',
+ | policy_type => 'ROW_FILTER', principal =>
'$principal',
+ | predicate_json => ${sqlLiteral(filter)})
+ |""".stripMargin),
+ Row(true)
+ )
+ }
+ try {
+ val first = spark
+ .sql("CALL sys.list_policies(database => 'sales', table => 'orders',
max_results => 1)")
+ .head()
+ assertThat(first.getString(3)).isEqualTo("first")
+ val pageToken = first.getString(7)
+ assertThat(pageToken).isNotEmpty.isNotEqualTo("1")
+
+ spark.sql(
+ "CALL sys.drop_policy(database => 'sales', table => 'orders', " +
+ "policy_type => 'ROW_FILTER', principal => 'first', if_exists =>
true)")
+
+ val second = spark
+ .sql(
+ "CALL sys.list_policies(database => 'sales', table => 'orders', " +
+ s"max_results => 1, page_token => ${sqlLiteral(pageToken)})")
+ .head()
+ assertThat(second.getString(3)).isEqualTo("second")
+ assertThat(second.isNullAt(7)).isTrue
+ } finally {
+ Seq("first", "second").foreach {
+ principal =>
+ spark.sql(
+ "CALL sys.drop_policy(database => 'sales', table => 'orders', " +
+ s"policy_type => 'ROW_FILTER', principal => '$principal',
if_exists => true)")
+ }
+ }
+ }
+
+ test("table lifecycle preserves policy enforcement and rejects unsafe schema
changes") {
+ restCatalogServer.setQueryPrincipals(Collections.singleton("analyst"))
+ spark.sql("""CREATE TABLE paimon.sales.lifecycle_orders (
+ | id INT,
+ | region STRING)
+ |TBLPROPERTIES ('query-auth.enabled' = 'true')
+ |""".stripMargin)
+ spark.sql("INSERT INTO paimon.sales.lifecycle_orders VALUES (1, 'APAC'),
(2, 'EMEA')")
+ val lifecycleFilter = stringEqualsPredicate(1, "region", "APAC")
+ checkAnswer(
+ spark.sql(s"""CALL sys.create_policy(
+ | database => 'sales', table => 'lifecycle_orders',
+ | policy_type => 'ROW_FILTER', predicate_json =>
${sqlLiteral(lifecycleFilter)},
+ | principal => 'analyst')
+ |""".stripMargin),
+ Row(true)
+ )
+
+ spark.sql("ALTER TABLE paimon.sales.lifecycle_orders RENAME TO
paimon.sales.renamed_orders")
+ assertThat(
+ spark
+ .sql("CALL sys.list_policies(database => 'sales', table =>
'renamed_orders', " +
+ "policy_type => 'ROW_FILTER', principal => 'analyst')")
+ .head()
+ .getString(1)).isEqualTo("renamed_orders")
+ assertThat(
+ paimonCatalog
+ .authTableQuery(Identifier.create("sales", "renamed_orders"), null)
+ .extractPredicate()).isNotNull
+
+ val disableAuth = intercept[Exception] {
+ spark
+ .sql("ALTER TABLE paimon.sales.renamed_orders " +
+ "SET TBLPROPERTIES ('query-auth.enabled' = 'false')")
+ .collect()
+ }
+ assertThat(disableAuth.getMessage).contains("Cannot disable
query-auth.enabled")
+
+ val renameColumn = intercept[Exception] {
+ spark
+ .sql("ALTER TABLE paimon.sales.renamed_orders RENAME COLUMN region TO
area")
+ .collect()
+ }
+ assertThat(renameColumn.getMessage).contains("column region")
+
+ spark.sql("DROP TABLE paimon.sales.renamed_orders")
+ spark.sql("""CREATE TABLE paimon.sales.renamed_orders (
+ | id INT,
+ | region STRING)
+ |TBLPROPERTIES ('query-auth.enabled' = 'true')
+ |""".stripMargin)
+ spark.sql("INSERT INTO paimon.sales.renamed_orders VALUES (1, 'APAC'), (2,
'EMEA')")
+ checkAnswer(
+ spark.sql("SELECT id, region FROM paimon.sales.renamed_orders ORDER BY
id"),
+ Seq(Row(1, "APAC"), Row(2, "EMEA"))
+ )
+ checkAnswer(
+ spark.sql(
+ "CALL sys.list_policies(database => 'sales', table =>
'renamed_orders', " +
+ "policy_type => 'ROW_FILTER', principal => 'analyst')"),
+ Nil
+ )
+ assertThat(
+ paimonCatalog
+ .authTableQuery(Identifier.create("sales", "renamed_orders"), null)
+ .extractPredicate()).isNull
+ }
+
+ test("management endpoints enforce target authorization") {
+ val resource =
+ new PermissionResource(ResourceType.TABLE, "sales", "orders", null, null)
+ restCatalogServer.denyManagementPermission(resource)
+ try {
+ val permissionError = intercept[Exception] {
+ spark
+ .sql("""CALL sys.grant_permission(
+ | resource_type => 'TABLE', access => 'SELECT',
+ | principal => 'analyst',
+ | database => 'sales', table => 'orders')
+ |""".stripMargin)
+ .collect()
+ }
+ assertThat(permissionError.getMessage).contains("cannot manage
permissions")
+
+ val policyError = intercept[Exception] {
+ spark
+ .sql("CALL sys.list_policies(database => 'sales', table =>
'orders')")
+ .collect()
+ }
+ assertThat(policyError.getMessage).contains("cannot manage permissions")
+ } finally {
+ restCatalogServer.allowManagementPermission(resource)
+ }
+ }
+
+ private def grantCatalogPermission(principal: String): Unit = {
+ checkAnswer(
+ spark.sql(s"""CALL sys.grant_permission(
+ | resource_type => 'CATALOG',
+ | access => 'CREATEDATABASE',
+ | principal => '$principal')
+ |""".stripMargin),
+ Row(true)
+ )
+ }
+
+ private def grantTablePermission(table: String, principal: String): Unit = {
+ checkAnswer(
+ spark.sql(s"""CALL sys.grant_permission(
+ | resource_type => 'TABLE', access => 'SELECT', principal
=> '$principal',
+ | database => 'sales', table => '$table')
+ |""".stripMargin),
+ Row(true)
+ )
+ }
+
+ private def stringEqualsPredicate(index: Int, column: String, constant:
String): String = {
+ JsonSerdeUtil.toFlatJson(
+ LeafPredicate.of(
+ new FieldTransform(new FieldRef(index, column, DataTypes.STRING())),
+ Equal.INSTANCE,
+ Collections.singletonList(BinaryString.fromString(constant))))
+ }
+
+ private def intEqualsPredicate(index: Int, column: String, constant: Int):
String = {
+ JsonSerdeUtil.toFlatJson(
+ LeafPredicate.of(
+ new FieldTransform(new FieldRef(index, column, DataTypes.INT())),
+ Equal.INSTANCE,
+ Collections.singletonList(Integer.valueOf(constant))))
+ }
+
+ private def constantStringTransform(constant: String): String = {
+ JsonSerdeUtil.toFlatJson(
+ new
ConcatTransform(Collections.singletonList(BinaryString.fromString(constant))))
+ }
+
+ private def concatFieldTransform(index: Int, column: String, suffix:
String): String = {
+ JsonSerdeUtil.toFlatJson(
+ new ConcatTransform(Arrays
+ .asList(new FieldRef(index, column, DataTypes.STRING()),
BinaryString.fromString(suffix))))
+ }
+
+ private def sqlLiteral(value: String): String = {
+ "'" + value.replace("'", "''") + "'"
+ }
+}
+
+/** Management procedures must fail clearly for catalogs without the REST
capability. */
+class PermissionProcedureUnsupportedCatalogTest extends PaimonSparkTestBase {
+
+ test("filesystem catalog does not expose permission management") {
+ val error = intercept[IllegalArgumentException] {
+ spark
+ .sql("""CALL sys.grant_permission(
+ | resource_type => 'CATALOG',
+ | access => 'CREATEDATABASE',
+ | principal => 'admin')
+ |""".stripMargin)
+ .collect()
+ }
+ assertThat(error.getMessage).contains("does not support permission or
policy management")
+ }
+}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogQualifiedCreateTableLikeTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogQualifiedCreateTableLikeTest.scala
index c4eb2cd644..350462fc1c 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogQualifiedCreateTableLikeTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogQualifiedCreateTableLikeTest.scala
@@ -139,6 +139,11 @@ class CatalogQualifiedCreateTableLikeTest extends
PaimonSparkTestBase {
Assertions.assertEquals("skip_file", skipFileCommand.targetIdent.name())
Assertions.assertEquals(Seq("test"),
skipFileCommand.targetIdent.namespace().toSeq)
+ val arrayCommand =
+ parseCreateTableLikeCommand("CREATE TABLE paimon.test.array LIKE
paimon.test.source_tbl")
+ Assertions.assertEquals("array", arrayCommand.targetIdent.name())
+ Assertions.assertEquals(Seq("test"),
arrayCommand.targetIdent.namespace().toSeq)
+
val nestedIdentifierCommand =
parseCreateTableLikeCommand(
"CREATE TABLE paimon.test.extra.target_tbl LIKE
paimon.test.extra.source_tbl")