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 28a2c01a65 [api] Add REST policy management (#9400)
28a2c01a65 is described below
commit 28a2c01a65bb3246a08d840c11f3c27a264f7cd9
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Aug 26 15:47:36 2026 +0800
[api] Add REST policy management (#9400)
---
.../org/apache/paimon/management/ColumnMask.java | 77 +++++++
.../org/apache/paimon/management/DataPolicy.java | 120 ++++++++++
.../paimon/management/ListPoliciesRequest.java | 102 +++++++++
.../paimon/management/PermissionResource.java | 7 +
.../apache/paimon/management/PolicyManagement.java | 70 ++++++
.../org/apache/paimon/management/PolicyType.java | 37 +++
.../org/apache/paimon/management/RowFilter.java | 64 ++++++
.../main/java/org/apache/paimon/rest/RESTApi.java | 58 +++++
.../apache/paimon/rest/RESTPolicyManagement.java | 72 ++++++
.../java/org/apache/paimon/rest/ResourcePaths.java | 15 ++
.../paimon/rest/requests/DropPolicyRequest.java | 99 ++++++++
.../apache/paimon/rest/requests/PolicyRequest.java | 96 ++++++++
.../paimon/rest/responses/ErrorResponse.java | 2 +
.../rest/responses/ListPoliciesResponse.java | 72 ++++++
.../management/PolicyManagementJsonTest.java | 176 +++++++++++++++
.../paimon/rest/RESTPolicyManagementTest.java | 251 +++++++++++++++++++++
.../requests/RequestJacksonCompatibilityTest.java | 2 +
.../java/org/apache/paimon/rest/RESTCatalog.java | 6 +
.../org/apache/paimon/rest/ResourcePathsTest.java | 24 ++
19 files changed, 1350 insertions(+)
diff --git
a/paimon-api/src/main/java/org/apache/paimon/management/ColumnMask.java
b/paimon-api/src/main/java/org/apache/paimon/management/ColumnMask.java
new file mode 100644
index 0000000000..c2dbadbbdb
--- /dev/null
+++ b/paimon-api/src/main/java/org/apache/paimon/management/ColumnMask.java
@@ -0,0 +1,77 @@
+/*
+ * 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.management;
+
+import org.apache.paimon.annotation.Experimental;
+
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.beans.ConstructorProperties;
+import java.nio.charset.StandardCharsets;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Protected column and serialized Paimon transform for a column-mask policy.
*/
+@Experimental
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ColumnMask {
+
+ public static final int MAX_TRANSFORM_BYTES = 60 * 1024;
+
+ private static final String FIELD_ON_COLUMN = "onColumn";
+ private static final String FIELD_TRANSFORM = "transform";
+
+ @JsonProperty(FIELD_ON_COLUMN)
+ private final String onColumn;
+
+ @JsonProperty(FIELD_TRANSFORM)
+ private final String transform;
+
+ @JsonCreator
+ @ConstructorProperties({FIELD_ON_COLUMN, FIELD_TRANSFORM})
+ public ColumnMask(
+ @JsonProperty(FIELD_ON_COLUMN) String onColumn,
+ @JsonProperty(FIELD_TRANSFORM) String transform) {
+ checkArgument(!isBlank(onColumn), "onColumn cannot be empty.");
+ checkArgument(!isBlank(transform), "transform cannot be empty.");
+ checkArgument(
+ transform.getBytes(StandardCharsets.UTF_8).length <=
MAX_TRANSFORM_BYTES,
+ "transform must not exceed %s UTF-8 bytes.",
+ MAX_TRANSFORM_BYTES);
+ this.onColumn = onColumn;
+ this.transform = transform;
+ }
+
+ @JsonGetter(FIELD_ON_COLUMN)
+ public String getOnColumn() {
+ return onColumn;
+ }
+
+ @JsonGetter(FIELD_TRANSFORM)
+ public String getTransform() {
+ return transform;
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.trim().isEmpty();
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/management/DataPolicy.java
b/paimon-api/src/main/java/org/apache/paimon/management/DataPolicy.java
new file mode 100644
index 0000000000..08fe3fb035
--- /dev/null
+++ b/paimon-api/src/main/java/org/apache/paimon/management/DataPolicy.java
@@ -0,0 +1,120 @@
+/*
+ * 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.management;
+
+import org.apache.paimon.annotation.Experimental;
+
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+import javax.annotation.Nullable;
+
+import java.beans.ConstructorProperties;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/**
+ * Principal-scoped row-filter or column-mask policy attached to one table.
+ *
+ * <p>A principal has at most one row filter per table and at most one mask
per table column. When
+ * policies are enforced, applicable row filters are combined with logical AND
and multiple
+ * effective masks for one column fail closed. Invalid predicates or
transforms also fail closed.
+ */
+@Experimental
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class DataPolicy {
+
+ private static final String FIELD_RESOURCE = "resource";
+ private static final String FIELD_ROW_FILTER = "rowFilter";
+ private static final String FIELD_COLUMN_MASK = "columnMask";
+ private static final String FIELD_PRINCIPAL = "principal";
+
+ @JsonProperty(FIELD_RESOURCE)
+ private final PermissionResource resource;
+
+ @Nullable
+ @JsonProperty(FIELD_ROW_FILTER)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private final RowFilter rowFilter;
+
+ @Nullable
+ @JsonProperty(FIELD_COLUMN_MASK)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private final ColumnMask columnMask;
+
+ @JsonProperty(FIELD_PRINCIPAL)
+ private final String principal;
+
+ @JsonCreator
+ @ConstructorProperties({FIELD_RESOURCE, FIELD_ROW_FILTER,
FIELD_COLUMN_MASK, FIELD_PRINCIPAL})
+ public DataPolicy(
+ @JsonProperty(FIELD_RESOURCE) PermissionResource resource,
+ @Nullable @JsonProperty(FIELD_ROW_FILTER) RowFilter rowFilter,
+ @Nullable @JsonProperty(FIELD_COLUMN_MASK) ColumnMask columnMask,
+ @JsonProperty(FIELD_PRINCIPAL) String principal) {
+ this.resource = checkNotNull(resource, "resource cannot be null");
+ resource.validatePolicyAttachment();
+ checkArgument(
+ (rowFilter == null) != (columnMask == null),
+ "A policy must contain exactly one of rowFilter and
columnMask.");
+ this.rowFilter = rowFilter;
+ this.columnMask = columnMask;
+ this.principal = PermissionAssignment.validatePrincipal(principal);
+ }
+
+ public static DataPolicy rowFilter(
+ PermissionResource resource, RowFilter rowFilter, String
principal) {
+ return new DataPolicy(resource, rowFilter, null, principal);
+ }
+
+ public static DataPolicy columnMask(
+ PermissionResource resource, ColumnMask columnMask, String
principal) {
+ return new DataPolicy(resource, null, columnMask, principal);
+ }
+
+ @JsonGetter(FIELD_RESOURCE)
+ public PermissionResource getResource() {
+ return resource;
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_ROW_FILTER)
+ public RowFilter getRowFilter() {
+ return rowFilter;
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_COLUMN_MASK)
+ public ColumnMask getColumnMask() {
+ return columnMask;
+ }
+
+ public PolicyType type() {
+ return rowFilter == null ? PolicyType.COLUMN_MASKING :
PolicyType.ROW_FILTER;
+ }
+
+ @JsonGetter(FIELD_PRINCIPAL)
+ public String getPrincipal() {
+ return principal;
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/management/ListPoliciesRequest.java
b/paimon-api/src/main/java/org/apache/paimon/management/ListPoliciesRequest.java
new file mode 100644
index 0000000000..e79186be3d
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/management/ListPoliciesRequest.java
@@ -0,0 +1,102 @@
+/*
+ * 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.management;
+
+import org.apache.paimon.annotation.Experimental;
+
+import javax.annotation.Nullable;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/** Filters for listing policies attached to an exact table resource. */
+@Experimental
+public class ListPoliciesRequest {
+
+ private final PermissionResource resource;
+ @Nullable private final PolicyType type;
+ @Nullable private final String principal;
+ @Nullable private final String column;
+ @Nullable private final String pageToken;
+ @Nullable private final Integer maxResults;
+
+ public ListPoliciesRequest(
+ PermissionResource resource,
+ @Nullable PolicyType type,
+ @Nullable String principal,
+ @Nullable String column,
+ @Nullable String pageToken,
+ @Nullable Integer maxResults) {
+ this.resource = checkNotNull(resource, "resource cannot be null");
+ resource.validatePolicyAttachment();
+ if (!isBlank(principal)) {
+ PermissionAssignment.validatePrincipal(principal);
+ }
+ checkArgument(maxResults == null || maxResults > 0, "maxResults must
be greater than 0.");
+ checkArgument(
+ maxResults == null || maxResults <=
ListPermissionsRequest.MAX_PAGE_SIZE,
+ "maxResults must be at most %s.",
+ ListPermissionsRequest.MAX_PAGE_SIZE);
+ this.type = type;
+ this.principal = isBlank(principal) ? null : principal;
+ checkArgument(
+ isBlank(column) || type == PolicyType.COLUMN_MASKING,
+ "column filter requires type COLUMN_MASKING.");
+ this.column = isBlank(column) ? null : column;
+ this.pageToken = pageToken;
+ this.maxResults = maxResults;
+ }
+
+ public PermissionResource getResource() {
+ return resource;
+ }
+
+ @Nullable
+ public PolicyType getType() {
+ return type;
+ }
+
+ @Nullable
+ public String getPrincipal() {
+ return principal;
+ }
+
+ @Nullable
+ public String getColumn() {
+ return column;
+ }
+
+ @Nullable
+ public String getPageToken() {
+ return pageToken;
+ }
+
+ @Nullable
+ public Integer getMaxResults() {
+ return maxResults;
+ }
+
+ public ListPoliciesRequest withPageToken(@Nullable String newPageToken) {
+ return new ListPoliciesRequest(resource, type, principal, column,
newPageToken, maxResults);
+ }
+
+ private static boolean isBlank(@Nullable String value) {
+ return value == null || value.trim().isEmpty();
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/management/PermissionResource.java
b/paimon-api/src/main/java/org/apache/paimon/management/PermissionResource.java
index f0100af100..b3d15373b8 100644
---
a/paimon-api/src/main/java/org/apache/paimon/management/PermissionResource.java
+++
b/paimon-api/src/main/java/org/apache/paimon/management/PermissionResource.java
@@ -122,6 +122,13 @@ public class PermissionResource {
return view;
}
+ /** Validates that this resource can carry a data policy in the current
contract. */
+ public void validatePolicyAttachment() {
+ checkArgument(
+ type == ResourceType.TABLE,
+ "Policies can currently be attached only to TABLE resources.");
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
diff --git
a/paimon-api/src/main/java/org/apache/paimon/management/PolicyManagement.java
b/paimon-api/src/main/java/org/apache/paimon/management/PolicyManagement.java
new file mode 100644
index 0000000000..6b33195b06
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/management/PolicyManagement.java
@@ -0,0 +1,70 @@
+/*
+ * 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.management;
+
+import org.apache.paimon.PagedList;
+import org.apache.paimon.annotation.Experimental;
+
+import javax.annotation.Nullable;
+
+/** Control-plane contract for row-filter and column-masking policies. */
+@Experimental
+public interface PolicyManagement {
+
+ PagedList<DataPolicy> listPolicies(ListPoliciesRequest request);
+
+ void createPolicy(DataPolicy policy) throws PolicyAlreadyExistException;
+
+ void dropPolicy(
+ PermissionResource resource,
+ PolicyType type,
+ String principal,
+ @Nullable String column,
+ boolean ignoreIfNotExists);
+
+ /** Exception for trying to create a policy that already exists. */
+ class PolicyAlreadyExistException extends Exception {
+
+ private final DataPolicy policy;
+
+ public PolicyAlreadyExistException(DataPolicy policy) {
+ this(policy, null);
+ }
+
+ public PolicyAlreadyExistException(DataPolicy policy, Throwable cause)
{
+ super(message(policy), cause);
+ this.policy = policy;
+ }
+
+ public DataPolicy policy() {
+ return policy;
+ }
+
+ private static String message(DataPolicy policy) {
+ String target = policy.type().name();
+ if (policy.getColumnMask() != null) {
+ target += "(" + policy.getColumnMask().getOnColumn() + ")";
+ }
+ PermissionResource resource = policy.getResource();
+ return String.format(
+ "%s policy for principal '%s' already exists on table
'%s.%s'.",
+ target, policy.getPrincipal(), resource.getDatabase(),
resource.getTable());
+ }
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/management/PolicyType.java
b/paimon-api/src/main/java/org/apache/paimon/management/PolicyType.java
new file mode 100644
index 0000000000..1312a2632f
--- /dev/null
+++ b/paimon-api/src/main/java/org/apache/paimon/management/PolicyType.java
@@ -0,0 +1,37 @@
+/*
+ * 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.management;
+
+import org.apache.paimon.annotation.Experimental;
+
+import javax.annotation.Nullable;
+
+import java.util.Locale;
+
+/** Fine-grained data policy types. */
+@Experimental
+public enum PolicyType {
+ ROW_FILTER,
+ COLUMN_MASKING;
+
+ @Nullable
+ public static PolicyType fromString(@Nullable String value) {
+ return value == null ? null : valueOf(value.toUpperCase(Locale.ROOT));
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/management/RowFilter.java
b/paimon-api/src/main/java/org/apache/paimon/management/RowFilter.java
new file mode 100644
index 0000000000..4af32042b5
--- /dev/null
+++ b/paimon-api/src/main/java/org/apache/paimon/management/RowFilter.java
@@ -0,0 +1,64 @@
+/*
+ * 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.management;
+
+import org.apache.paimon.annotation.Experimental;
+
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.beans.ConstructorProperties;
+import java.nio.charset.StandardCharsets;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Serialized Paimon predicate for a row-filter policy. */
+@Experimental
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class RowFilter {
+
+ public static final int MAX_PREDICATE_BYTES = 60 * 1024;
+
+ private static final String FIELD_PREDICATE = "predicate";
+
+ @JsonProperty(FIELD_PREDICATE)
+ private final String predicate;
+
+ @JsonCreator
+ @ConstructorProperties({FIELD_PREDICATE})
+ public RowFilter(@JsonProperty(FIELD_PREDICATE) String predicate) {
+ checkArgument(!isBlank(predicate), "predicate cannot be empty.");
+ checkArgument(
+ predicate.getBytes(StandardCharsets.UTF_8).length <=
MAX_PREDICATE_BYTES,
+ "predicate must not exceed %s UTF-8 bytes.",
+ MAX_PREDICATE_BYTES);
+ this.predicate = predicate;
+ }
+
+ @JsonGetter(FIELD_PREDICATE)
+ public String getPredicate() {
+ return predicate;
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.trim().isEmpty();
+ }
+}
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
index e2f5cc3702..b4b110cfec 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
@@ -26,9 +26,12 @@ import org.apache.paimon.annotation.VisibleForTesting;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.consumer.ConsumerInfo;
import org.apache.paimon.function.FunctionChange;
+import org.apache.paimon.management.DataPolicy;
import org.apache.paimon.management.ListPermissionsRequest;
+import org.apache.paimon.management.ListPoliciesRequest;
import org.apache.paimon.management.PermissionAssignment;
import org.apache.paimon.management.PermissionResource;
+import org.apache.paimon.management.PolicyType;
import org.apache.paimon.options.Options;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.partition.PartitionStatistics;
@@ -51,11 +54,13 @@ import org.apache.paimon.rest.requests.CreateTableRequest;
import org.apache.paimon.rest.requests.CreateTagRequest;
import org.apache.paimon.rest.requests.CreateViewRequest;
import org.apache.paimon.rest.requests.DropPartitionsRequest;
+import org.apache.paimon.rest.requests.DropPolicyRequest;
import org.apache.paimon.rest.requests.ForwardBranchRequest;
import org.apache.paimon.rest.requests.GrantPermissionRequest;
import org.apache.paimon.rest.requests.ListPartitionsByFilterRequest;
import org.apache.paimon.rest.requests.ListPartitionsByNamesRequest;
import org.apache.paimon.rest.requests.MarkDonePartitionsRequest;
+import org.apache.paimon.rest.requests.PolicyRequest;
import org.apache.paimon.rest.requests.RegisterTableRequest;
import org.apache.paimon.rest.requests.RenameTableRequest;
import org.apache.paimon.rest.requests.ReplaceTableRequest;
@@ -86,6 +91,7 @@ import
org.apache.paimon.rest.responses.ListFunctionsGloballyResponse;
import org.apache.paimon.rest.responses.ListFunctionsResponse;
import org.apache.paimon.rest.responses.ListPartitionsResponse;
import org.apache.paimon.rest.responses.ListPermissionsResponse;
+import org.apache.paimon.rest.responses.ListPoliciesResponse;
import org.apache.paimon.rest.responses.ListSnapshotsResponse;
import org.apache.paimon.rest.responses.ListTableDetailsResponse;
import org.apache.paimon.rest.responses.ListTablesGloballyResponse;
@@ -125,6 +131,7 @@ import static
org.apache.paimon.rest.RESTFunctionValidator.isValidFunctionName;
import static org.apache.paimon.rest.RESTUtil.extractPrefixMap;
import static
org.apache.paimon.rest.auth.AuthProviderFactory.createAuthProvider;
import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
/**
* REST API for REST Catalog.
@@ -880,6 +887,57 @@ public class RESTApi {
restAuthFunction);
}
+ /** Lists policies attached to an exact table resource. */
+ @Experimental
+ public ListPoliciesResponse listPolicies(ListPoliciesRequest request) {
+ Map<String, String> queryParams = Maps.newHashMap();
+ if (request.getType() != null) {
+ putQueryParameter(queryParams, "type", request.getType().name());
+ }
+ putQueryParameter(queryParams, "principal", request.getPrincipal());
+ putQueryParameter(queryParams, "column", request.getColumn());
+ if (request.getMaxResults() != null) {
+ queryParams.put(MAX_RESULTS, request.getMaxResults().toString());
+ }
+ putQueryParameter(queryParams, PAGE_TOKEN, request.getPageToken());
+ return client.get(
+ resourcePaths.policies(request.getResource()),
+ queryParams,
+ ListPoliciesResponse.class,
+ restAuthFunction);
+ }
+
+ /** Creates a principal policy on its attachment resource. */
+ @Experimental
+ public void createPolicy(DataPolicy policy) {
+ client.post(
+ resourcePaths.policies(policy.getResource()),
+ new PolicyRequest(policy),
+ restAuthFunction);
+ }
+
+ /** Drops a principal policy from its exact attachment resource. */
+ @Experimental
+ public void dropPolicy(
+ PermissionResource resource,
+ PolicyType type,
+ String principal,
+ @Nullable String column,
+ boolean ignoreIfNotExists) {
+ checkNotNull(resource, "resource cannot be
null").validatePolicyAttachment();
+ try {
+ client.post(
+ resourcePaths.dropPolicy(resource),
+ new DropPolicyRequest(type, principal, column),
+ restAuthFunction);
+ } catch (NoSuchResourceException e) {
+ if (!ignoreIfNotExists
+ ||
!ErrorResponse.RESOURCE_TYPE_POLICY.equals(e.resourceType())) {
+ throw e;
+ }
+ }
+ }
+
/**
* Drop table.
*
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/RESTPolicyManagement.java
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTPolicyManagement.java
new file mode 100644
index 0000000000..45a42f6cf8
--- /dev/null
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTPolicyManagement.java
@@ -0,0 +1,72 @@
+/*
+ * 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.rest;
+
+import org.apache.paimon.PagedList;
+import org.apache.paimon.annotation.Experimental;
+import org.apache.paimon.management.DataPolicy;
+import org.apache.paimon.management.ListPoliciesRequest;
+import org.apache.paimon.management.PermissionResource;
+import org.apache.paimon.management.PolicyManagement;
+import
org.apache.paimon.management.PolicyManagement.PolicyAlreadyExistException;
+import org.apache.paimon.management.PolicyType;
+import org.apache.paimon.rest.exceptions.AlreadyExistsException;
+import org.apache.paimon.rest.responses.ErrorResponse;
+import org.apache.paimon.rest.responses.ListPoliciesResponse;
+
+import javax.annotation.Nullable;
+
+/** REST implementation of data policy management for a configured catalog
prefix. */
+@Experimental
+public class RESTPolicyManagement implements PolicyManagement {
+
+ private final RESTApi api;
+
+ public RESTPolicyManagement(RESTApi api) {
+ this.api = api;
+ }
+
+ @Override
+ public PagedList<DataPolicy> listPolicies(ListPoliciesRequest request) {
+ ListPoliciesResponse response = api.listPolicies(request);
+ return new PagedList<>(response.getPolicies(),
response.getNextPageToken());
+ }
+
+ @Override
+ public void createPolicy(DataPolicy policy) throws
PolicyAlreadyExistException {
+ try {
+ api.createPolicy(policy);
+ } catch (AlreadyExistsException e) {
+ if (ErrorResponse.RESOURCE_TYPE_POLICY.equals(e.resourceType())) {
+ throw new PolicyAlreadyExistException(policy, e);
+ }
+ throw e;
+ }
+ }
+
+ @Override
+ public void dropPolicy(
+ PermissionResource resource,
+ PolicyType type,
+ String principal,
+ @Nullable String column,
+ boolean ignoreIfNotExists) {
+ api.dropPolicy(resource, type, principal, column, ignoreIfNotExists);
+ }
+}
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
index 6ae7ebf8c3..4cef311061 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
@@ -19,6 +19,7 @@
package org.apache.paimon.rest;
import org.apache.paimon.annotation.Experimental;
+import org.apache.paimon.management.PermissionResource;
import org.apache.paimon.options.Options;
import org.apache.paimon.shade.guava30.com.google.common.base.Joiner;
@@ -44,6 +45,7 @@ public class ResourcePaths {
protected static final String FUNCTIONS = "functions";
protected static final String FUNCTION_DETAILS = "function-details";
protected static final String PERMISSIONS = "permissions";
+ protected static final String POLICIES = "policies";
protected static final String ID = "id";
private static final Joiner SLASH = Joiner.on("/").skipNulls();
@@ -77,6 +79,19 @@ public class ResourcePaths {
return SLASH.join(permissions(), "revoke");
}
+ /** Policy collection nested below its attachment resource. */
+ @Experimental
+ public String policies(PermissionResource resource) {
+ resource.validatePolicyAttachment();
+ return SLASH.join(table(resource.getDatabase(), resource.getTable()),
POLICIES);
+ }
+
+ /** Action endpoint for dropping one policy from its attachment resource.
*/
+ @Experimental
+ public String dropPolicy(PermissionResource resource) {
+ return SLASH.join(policies(resource), "drop");
+ }
+
public String databases() {
return SLASH.join(V1, prefix, DATABASES);
}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/DropPolicyRequest.java
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/DropPolicyRequest.java
new file mode 100644
index 0000000000..64b7b83c0e
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/DropPolicyRequest.java
@@ -0,0 +1,99 @@
+/*
+ * 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.rest.requests;
+
+import org.apache.paimon.annotation.Experimental;
+import org.apache.paimon.management.PermissionAssignment;
+import org.apache.paimon.management.PolicyType;
+import org.apache.paimon.rest.RESTRequest;
+
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+import javax.annotation.Nullable;
+
+import java.beans.ConstructorProperties;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/** Request for dropping one principal's row filter or column mask. */
+@Experimental
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class DropPolicyRequest implements RESTRequest {
+
+ private static final String FIELD_TYPE = "type";
+ private static final String FIELD_PRINCIPAL = "principal";
+ private static final String FIELD_COLUMN = "column";
+
+ private final PolicyType type;
+ private final String principal;
+ @Nullable private final String column;
+
+ @JsonCreator
+ @ConstructorProperties({FIELD_TYPE, FIELD_PRINCIPAL, FIELD_COLUMN})
+ public DropPolicyRequest(
+ @JsonProperty(FIELD_TYPE) PolicyType type,
+ @JsonProperty(FIELD_PRINCIPAL) String principal,
+ @Nullable @JsonProperty(FIELD_COLUMN) String column) {
+ this.type = checkNotNull(type, "policy type cannot be null");
+ this.principal = validatePrincipal(principal);
+ if (type == PolicyType.ROW_FILTER) {
+ checkArgument(isBlank(column), "ROW_FILTER identity cannot contain
a column.");
+ this.column = null;
+ } else {
+ checkArgument(!isBlank(column), "column is required for
COLUMN_MASKING identity.");
+ this.column = column;
+ }
+ }
+
+ @JsonGetter(FIELD_TYPE)
+ public PolicyType getType() {
+ return type;
+ }
+
+ @JsonGetter(FIELD_PRINCIPAL)
+ public String getPrincipal() {
+ return principal;
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_COLUMN)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public String getColumn() {
+ return column;
+ }
+
+ private static String validatePrincipal(String principal) {
+ checkArgument(
+ principal != null && !principal.trim().isEmpty(), "principal
cannot be empty.");
+ checkArgument(
+ principal.length() <=
PermissionAssignment.MAX_PRINCIPAL_LENGTH,
+ "principal must contain at most %s characters.",
+ PermissionAssignment.MAX_PRINCIPAL_LENGTH);
+ return principal;
+ }
+
+ private static boolean isBlank(@Nullable String value) {
+ return value == null || value.trim().isEmpty();
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/PolicyRequest.java
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/PolicyRequest.java
new file mode 100644
index 0000000000..f1e9a0ed53
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/PolicyRequest.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.rest.requests;
+
+import org.apache.paimon.annotation.Experimental;
+import org.apache.paimon.management.ColumnMask;
+import org.apache.paimon.management.DataPolicy;
+import org.apache.paimon.management.PermissionResource;
+import org.apache.paimon.management.RowFilter;
+import org.apache.paimon.rest.RESTRequest;
+
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnore;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+import javax.annotation.Nullable;
+
+import java.beans.ConstructorProperties;
+
+/** Create payload for a principal policy whose table is identified by the
request path. */
+@Experimental
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class PolicyRequest implements RESTRequest {
+
+ private static final String FIELD_ROW_FILTER = "rowFilter";
+ private static final String FIELD_COLUMN_MASK = "columnMask";
+ private static final String FIELD_PRINCIPAL = "principal";
+
+ @Nullable private final RowFilter rowFilter;
+ @Nullable private final ColumnMask columnMask;
+ private final String principal;
+
+ public PolicyRequest(DataPolicy policy) {
+ this(policy.getRowFilter(), policy.getColumnMask(),
policy.getPrincipal());
+ }
+
+ @JsonCreator
+ @ConstructorProperties({FIELD_ROW_FILTER, FIELD_COLUMN_MASK,
FIELD_PRINCIPAL})
+ public PolicyRequest(
+ @Nullable @JsonProperty(FIELD_ROW_FILTER) RowFilter rowFilter,
+ @Nullable @JsonProperty(FIELD_COLUMN_MASK) ColumnMask columnMask,
+ @JsonProperty(FIELD_PRINCIPAL) String principal) {
+ this.rowFilter = rowFilter;
+ this.columnMask = columnMask;
+ this.principal = principal;
+ }
+
+ public DataPolicy policy(PermissionResource resource) {
+ return new DataPolicy(resource, rowFilter, columnMask, principal);
+ }
+
+ /** Creating a principal policy cannot be replayed after an ambiguous
server response. */
+ @JsonIgnore
+ @Override
+ public boolean isRetrySafe() {
+ return false;
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_ROW_FILTER)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public RowFilter getRowFilter() {
+ return rowFilter;
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_COLUMN_MASK)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public ColumnMask getColumnMask() {
+ return columnMask;
+ }
+
+ @JsonGetter(FIELD_PRINCIPAL)
+ public String getPrincipal() {
+ return principal;
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
index 4eb52308dd..4fe52b1d0b 100644
---
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
@@ -51,6 +51,8 @@ public class ErrorResponse implements RESTResponse {
public static final String RESOURCE_TYPE_FUNCTION = "FUNCTION";
+ public static final String RESOURCE_TYPE_POLICY = "POLICY";
+
public static final String RESOURCE_TYPE_DEFINITION = "DEFINITION";
private static final String FIELD_MESSAGE = "message";
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListPoliciesResponse.java
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListPoliciesResponse.java
new file mode 100644
index 0000000000..95595f066d
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListPoliciesResponse.java
@@ -0,0 +1,72 @@
+/*
+ * 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.rest.responses;
+
+import org.apache.paimon.annotation.Experimental;
+import org.apache.paimon.management.DataPolicy;
+
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+import javax.annotation.Nullable;
+
+import java.beans.ConstructorProperties;
+import java.util.List;
+
+/** Response for listing data policies. */
+@Experimental
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ListPoliciesResponse implements PagedResponse<DataPolicy> {
+
+ private static final String FIELD_POLICIES = "policies";
+ private static final String FIELD_NEXT_PAGE_TOKEN = "nextPageToken";
+
+ private final List<DataPolicy> policies;
+ @Nullable private final String nextPageToken;
+
+ @JsonCreator
+ @ConstructorProperties({FIELD_POLICIES, FIELD_NEXT_PAGE_TOKEN})
+ public ListPoliciesResponse(
+ @JsonProperty(FIELD_POLICIES) List<DataPolicy> policies,
+ @Nullable @JsonProperty(FIELD_NEXT_PAGE_TOKEN) String
nextPageToken) {
+ this.policies = policies;
+ this.nextPageToken = nextPageToken;
+ }
+
+ @JsonGetter(FIELD_POLICIES)
+ public List<DataPolicy> getPolicies() {
+ return policies;
+ }
+
+ @Override
+ @Nullable
+ @JsonGetter(FIELD_NEXT_PAGE_TOKEN)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public String getNextPageToken() {
+ return nextPageToken;
+ }
+
+ @Override
+ public List<DataPolicy> data() {
+ return policies;
+ }
+}
diff --git
a/paimon-api/src/test/java/org/apache/paimon/management/PolicyManagementJsonTest.java
b/paimon-api/src/test/java/org/apache/paimon/management/PolicyManagementJsonTest.java
new file mode 100644
index 0000000000..53c1905144
--- /dev/null
+++
b/paimon-api/src/test/java/org/apache/paimon/management/PolicyManagementJsonTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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.management;
+
+import org.apache.paimon.rest.RESTApi;
+import org.apache.paimon.rest.requests.DropPolicyRequest;
+import org.apache.paimon.rest.requests.PolicyRequest;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** JSON and validation tests for data-policy management contracts. */
+public class PolicyManagementJsonTest {
+
+ private static final String PREDICATE_JSON =
+ "{\"kind\":\"LEAF\",\"transform\":{\"name\":\"FIELD_REF\","
+ +
"\"fieldRef\":{\"index\":0,\"name\":\"region\",\"type\":\"STRING\"}},"
+ + "\"function\":\"EQUAL\",\"literals\":[\"APAC\"]}";
+ private static final String TRANSFORM_JSON =
+
"{\"name\":\"CONCAT\",\"inputs\":[{\"index\":0,\"name\":\"region\","
+ + "\"type\":\"STRING\"},\"****\"]}";
+
+ @Test
+ void testPolicyDefinitionsRoundTripWithShadedAndExternalJackson() throws
Exception {
+ DataPolicy policy =
+ DataPolicy.columnMask(
+ tableResource(), new ColumnMask("email",
TRANSFORM_JSON), "analyst");
+ String json = RESTApi.toJson(policy);
+
+ DataPolicy shaded = RESTApi.fromJson(json, DataPolicy.class);
+ DataPolicy external =
+ new
com.fasterxml.jackson.databind.ObjectMapper().readValue(json, DataPolicy.class);
+ for (DataPolicy roundTrip : Arrays.asList(shaded, external)) {
+ assertThat(roundTrip.type()).isEqualTo(PolicyType.COLUMN_MASKING);
+ assertThat(roundTrip.getResource()).isEqualTo(tableResource());
+
assertThat(roundTrip.getColumnMask().getOnColumn()).isEqualTo("email");
+
assertThat(roundTrip.getColumnMask().getTransform()).isEqualTo(TRANSFORM_JSON);
+ assertThat(roundTrip.getPrincipal()).isEqualTo("analyst");
+ }
+
+ PolicyRequest request = new PolicyRequest(policy);
+ assertThat(request.isRetrySafe()).isFalse();
+ Map<?, ?> wire = RESTApi.fromJson(RESTApi.toJson(request), Map.class);
+ assertThat(wire).hasSize(2);
+ assertThat(wire.get("columnMask")).isNotNull();
+ assertThat(wire.get("principal")).isEqualTo("analyst");
+
assertThat(request.policy(tableResource()).getResource()).isEqualTo(tableResource());
+ }
+
+ @Test
+ void testRowFilterRoundTrip() throws Exception {
+ DataPolicy policy =
+ DataPolicy.rowFilter(tableResource(), new
RowFilter(PREDICATE_JSON), "analyst");
+
+ DataPolicy roundTrip = RESTApi.fromJson(RESTApi.toJson(policy),
DataPolicy.class);
+ assertThat(roundTrip.type()).isEqualTo(PolicyType.ROW_FILTER);
+
assertThat(roundTrip.getRowFilter().getPredicate()).isEqualTo(PREDICATE_JSON);
+ assertThat(roundTrip.getColumnMask()).isNull();
+ }
+
+ @Test
+ void testPolicyValidationAndPayloadBounds() {
+ assertThatThrownBy(() -> new RowFilter(" "))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("predicate");
+ assertThatThrownBy(() -> new ColumnMask("email", " "))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("transform");
+ assertThatThrownBy(
+ () ->
+ new DataPolicy(
+ tableResource(),
+ new RowFilter(PREDICATE_JSON),
+ new ColumnMask("email",
TRANSFORM_JSON),
+ "analyst"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("exactly one");
+ assertThatThrownBy(
+ () ->
+ DataPolicy.columnMask(
+ catalogResource(),
+ new ColumnMask("email",
TRANSFORM_JSON),
+ "analyst"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("TABLE");
+ assertThatThrownBy(() -> new RowFilter(repeat('p',
RowFilter.MAX_PREDICATE_BYTES + 1)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("UTF-8 bytes");
+ assertThatThrownBy(
+ () ->
+ new ColumnMask(
+ "email", repeat('t',
ColumnMask.MAX_TRANSFORM_BYTES + 1)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("UTF-8 bytes");
+ }
+
+ @Test
+ void testDropPolicyRequestRoundTripAndIdentityValidation() throws
Exception {
+ DropPolicyRequest request =
+ new DropPolicyRequest(PolicyType.COLUMN_MASKING, "analyst",
"email");
+ DropPolicyRequest roundTrip =
+ RESTApi.fromJson(RESTApi.toJson(request),
DropPolicyRequest.class);
+
+ assertThat(roundTrip.getType()).isEqualTo(PolicyType.COLUMN_MASKING);
+ assertThat(roundTrip.getPrincipal()).isEqualTo("analyst");
+ assertThat(roundTrip.getColumn()).isEqualTo("email");
+ assertThatThrownBy(() -> new
DropPolicyRequest(PolicyType.COLUMN_MASKING, "analyst", null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("column is required");
+ assertThatThrownBy(() -> new DropPolicyRequest(PolicyType.ROW_FILTER,
"analyst", "email"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("cannot contain a column");
+ }
+
+ @Test
+ void testListPoliciesValidationAndOpaquePageToken() {
+ ListPoliciesRequest request =
+ new ListPoliciesRequest(
+ tableResource(), PolicyType.COLUMN_MASKING, "analyst",
"email", null, 25);
+
+ assertThat(request.withPageToken(" \t").getPageToken()).isEqualTo("
\t");
+ assertThatThrownBy(
+ () ->
+ new ListPoliciesRequest(
+ tableResource(), null, null, "email",
null, 25))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("COLUMN_MASKING");
+ assertThatThrownBy(
+ () ->
+ new ListPoliciesRequest(
+ tableResource(), null, null, null,
null, 1001))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("at most 1000");
+ assertThatThrownBy(
+ () ->
+ new ListPoliciesRequest(
+ catalogResource(), null, null, null,
null, 25))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("TABLE");
+ }
+
+ private static PermissionResource catalogResource() {
+ return new PermissionResource(ResourceType.CATALOG, null, null, null,
null);
+ }
+
+ private static PermissionResource tableResource() {
+ return new PermissionResource(ResourceType.TABLE, "sales", "orders",
null, null);
+ }
+
+ private static String repeat(char value, int length) {
+ char[] values = new char[length];
+ Arrays.fill(values, value);
+ return new String(values);
+ }
+}
diff --git
a/paimon-api/src/test/java/org/apache/paimon/rest/RESTPolicyManagementTest.java
b/paimon-api/src/test/java/org/apache/paimon/rest/RESTPolicyManagementTest.java
new file mode 100644
index 0000000000..a09e0e902a
--- /dev/null
+++
b/paimon-api/src/test/java/org/apache/paimon/rest/RESTPolicyManagementTest.java
@@ -0,0 +1,251 @@
+/*
+ * 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.rest;
+
+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.PermissionResource;
+import org.apache.paimon.management.PolicyManagement;
+import
org.apache.paimon.management.PolicyManagement.PolicyAlreadyExistException;
+import org.apache.paimon.management.PolicyType;
+import org.apache.paimon.management.ResourceType;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.rest.exceptions.AlreadyExistsException;
+import org.apache.paimon.rest.exceptions.NoSuchResourceException;
+import org.apache.paimon.utils.JsonSerdeUtil;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.apache.paimon.rest.RESTCatalogInternalOptions.PREFIX;
+import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN;
+import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN_PROVIDER;
+import static org.apache.paimon.rest.RESTCatalogOptions.URI;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Behavioral tests for REST data-policy management. */
+public class RESTPolicyManagementTest {
+
+ private static final String COLLECTION_PATH =
+ "/v1/catalog+id/databases/sales/tables/orders/policies";
+ private static final String DROP_PATH = COLLECTION_PATH + "/drop";
+
+ private HttpServer server;
+ private PolicyManagement management;
+ private final AtomicReference<String> listQuery = new AtomicReference<>();
+ private final AtomicReference<String> createBody = new AtomicReference<>();
+ private final AtomicReference<String> createError = new
AtomicReference<>();
+ private final AtomicReference<String> dropBody = new AtomicReference<>();
+ private final AtomicReference<String> dropError = new AtomicReference<>();
+ private final AtomicInteger dropCalls = new AtomicInteger();
+
+ @BeforeEach
+ void setUp() throws Exception {
+ server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.createContext(
+ "/v1/",
+ exchange -> {
+ String path = exchange.getRequestURI().getRawPath();
+ String method = exchange.getRequestMethod();
+ if (COLLECTION_PATH.equals(path) && "GET".equals(method)) {
+ listQuery.set(exchange.getRequestURI().getRawQuery());
+ respond(exchange, 200, listResponse());
+ } else if (COLLECTION_PATH.equals(path) &&
"POST".equals(method)) {
+ createBody.set(readBody(exchange));
+ if (createError.get() == null) {
+ respond(exchange, 200, null);
+ } else {
+ respond(exchange, 409, createError.get());
+ }
+ } else if (DROP_PATH.equals(path) &&
"POST".equals(method)) {
+ dropBody.set(readBody(exchange));
+ dropCalls.incrementAndGet();
+ if (dropError.get() == null) {
+ respond(exchange, 200, null);
+ } else {
+ respond(exchange, 404, dropError.get());
+ }
+ } else {
+ respond(exchange, 404,
"{\"message\":\"missing\",\"code\":404}");
+ }
+ });
+ server.start();
+
+ Options options = new Options();
+ options.set(URI, "http://127.0.0.1:" + server.getAddress().getPort());
+ options.set(TOKEN_PROVIDER, "bear");
+ options.set(TOKEN, "secret");
+ options.set(PREFIX, "catalog id");
+ management = new RESTPolicyManagement(new RESTApi(options, false));
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void testListUsesResourceNestedPathAndIdentityFilters() {
+ PagedList<DataPolicy> policies =
+ management.listPolicies(
+ new ListPoliciesRequest(
+ tableResource(),
+ PolicyType.COLUMN_MASKING,
+ "analyst",
+ "email",
+ "start",
+ 25));
+
+ assertThat(policies.getElements()).hasSize(1);
+ assertThat(policies.getNextPageToken()).isEqualTo("next");
+ assertThat(listQuery.get())
+ .contains("type=COLUMN_MASKING")
+ .contains("principal=analyst")
+ .contains("column=email")
+ .contains("maxResults=25")
+ .contains("pageToken=start");
+ }
+
+ @Test
+ void testCreateAndDropUsePostEndpoints() throws Exception {
+ DataPolicy policy = policy();
+ management.createPolicy(policy);
+ management.dropPolicy(
+ policy.getResource(),
+ policy.type(),
+ policy.getPrincipal(),
+ policy.getColumnMask().getOnColumn(),
+ false);
+
+ assertThat(createBody.get()).contains("\"principal\":\"analyst\"");
+ assertThat(createBody.get()).doesNotContain("\"resource\"");
+ assertThat(dropBody.get())
+ .contains("\"type\":\"COLUMN_MASKING\"")
+ .contains("\"column\":\"email\"");
+ assertThat(dropCalls).hasValue(1);
+ }
+
+ @Test
+ void testCreateMapsOnlyPolicyConflict() {
+ DataPolicy policy = policy();
+ createError.set(
+ "{\"resourceType\":\"POLICY\",\"resourceName\":"
+ + "\"COLUMN_MASKING:analyst:email\","
+ + "\"message\":\"already exists\",\"code\":409}");
+
+ assertThatThrownBy(() -> management.createPolicy(policy))
+ .isInstanceOf(PolicyAlreadyExistException.class)
+ .hasMessageContaining("COLUMN_MASKING(email)")
+ .hasCauseInstanceOf(AlreadyExistsException.class);
+
+ createError.set(
+ "{\"resourceType\":\"TABLE\",\"resourceName\":\"orders\","
+ + "\"message\":\"table conflict\",\"code\":409}");
+ assertThatThrownBy(() -> management.createPolicy(policy))
+ .isInstanceOf(AlreadyExistsException.class)
+ .hasMessageContaining("table conflict");
+ }
+
+ @Test
+ void testDropIfExistsOnlyIgnoresMissingPolicy() {
+ DataPolicy policy = policy();
+ dropError.set(
+ "{\"resourceType\":\"POLICY\",\"resourceName\":"
+ + "\"COLUMN_MASKING:analyst:email\","
+ + "\"message\":\"missing\",\"code\":404}");
+
+ management.dropPolicy(
+ policy.getResource(),
+ policy.type(),
+ policy.getPrincipal(),
+ policy.getColumnMask().getOnColumn(),
+ true);
+
+ dropError.set(
+ "{\"resourceType\":\"TABLE\",\"resourceName\":\"orders\","
+ + "\"message\":\"missing table\",\"code\":404}");
+ assertThatThrownBy(
+ () ->
+ management.dropPolicy(
+ policy.getResource(),
+ policy.type(),
+ policy.getPrincipal(),
+ policy.getColumnMask().getOnColumn(),
+ true))
+ .isInstanceOf(NoSuchResourceException.class)
+ .hasMessageContaining("missing table");
+ }
+
+ private static DataPolicy policy() {
+ return DataPolicy.columnMask(
+ tableResource(),
+ new ColumnMask(
+ "email",
+ "{\"name\":\"FIELD_REF\",\"fieldRef\":{\"index\":0,"
+ + "\"name\":\"region\",\"type\":\"STRING\"}}"),
+ "analyst");
+ }
+
+ private static PermissionResource tableResource() {
+ return new PermissionResource(ResourceType.TABLE, "sales", "orders",
null, null);
+ }
+
+ private static String listResponse() {
+ return "{\"policies\":[" + policyJson() +
"],\"nextPageToken\":\"next\"}";
+ }
+
+ private static String policyJson() {
+ return JsonSerdeUtil.toFlatJson(policy());
+ }
+
+ private static String readBody(HttpExchange exchange) throws IOException {
+ byte[] data = new byte[8192];
+ int read = exchange.getRequestBody().read(data);
+ return read < 0 ? "" : new String(data, 0, read,
StandardCharsets.UTF_8);
+ }
+
+ private static void respond(HttpExchange exchange, int code, String body)
throws IOException {
+ if (body == null) {
+ exchange.sendResponseHeaders(code, 0);
+ exchange.getResponseBody().close();
+ } else {
+ byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(code, bytes.length);
+ try (OutputStream output = exchange.getResponseBody()) {
+ output.write(bytes);
+ }
+ exchange.close();
+ }
+ }
+}
diff --git
a/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java
b/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java
index 30ebab5463..9e645db3b7 100644
---
a/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java
+++
b/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java
@@ -169,7 +169,9 @@ public class RequestJacksonCompatibilityTest {
CreatePartitionsRequest.class,
CreateTableRequest.class,
CreateViewRequest.class,
+ DropPolicyRequest.class,
GrantPermissionRequest.class,
+ PolicyRequest.class,
RegisterTableRequest.class,
RenameTableRequest.class,
ReplaceTableRequest.class,
diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
index 3c668aeacb..9a21f3fa41 100644
--- a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
@@ -41,6 +41,7 @@ import org.apache.paimon.fs.cache.LocalCacheManager;
import org.apache.paimon.function.Function;
import org.apache.paimon.function.FunctionChange;
import org.apache.paimon.management.PermissionManagement;
+import org.apache.paimon.management.PolicyManagement;
import org.apache.paimon.options.Options;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.partition.PartitionStatistics;
@@ -144,6 +145,11 @@ public class RESTCatalog implements Catalog {
return new RESTPermissionManagement(api);
}
+ @Experimental
+ public PolicyManagement policyManagement() {
+ return new RESTPolicyManagement(api);
+ }
+
@Override
public List<String> listDatabases() {
return api.listDatabases();
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java
b/paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java
index 432ea8f776..b6f0f39f38 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java
@@ -18,9 +18,13 @@
package org.apache.paimon.rest;
+import org.apache.paimon.management.PermissionResource;
+import org.apache.paimon.management.ResourceType;
+
import org.junit.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
/** Test for {@link ResourcePaths}. */
public class ResourcePathsTest {
@@ -47,4 +51,24 @@ public class ResourcePathsTest {
assertEquals("/v1/catalog%2Fid/permissions/grant",
resourcePaths.grantPermission());
assertEquals("/v1/catalog%2Fid/permissions/revoke",
resourcePaths.revokePermission());
}
+
+ @Test
+ public void testPoliciesAreNestedUnderAttachmentResource() {
+ ResourcePaths paths = new ResourcePaths("catalog/id");
+ PermissionResource catalog =
+ new PermissionResource(ResourceType.CATALOG, null, null, null,
null);
+ PermissionResource database =
+ new PermissionResource(ResourceType.DATABASE, "sales db",
null, null, null);
+ PermissionResource table =
+ new PermissionResource(ResourceType.TABLE, "sales db",
"orders/all", null, null);
+
+ assertThrows(IllegalArgumentException.class, () ->
paths.policies(catalog));
+ assertThrows(IllegalArgumentException.class, () ->
paths.policies(database));
+ assertEquals(
+
"/v1/catalog%2Fid/databases/sales+db/tables/orders%2Fall/policies",
+ paths.policies(table));
+ assertEquals(
+
"/v1/catalog%2Fid/databases/sales+db/tables/orders%2Fall/policies/drop",
+ paths.dropPolicy(table));
+ }
}