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 d99b376cd3 [api] Add REST permission management (#9393)
d99b376cd3 is described below
commit d99b376cd3df31b782d57f8b9d045292f26c5b41
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Aug 26 13:58:00 2026 +0800
[api] Add REST permission management (#9393)
---
.../paimon/management/ListPermissionsRequest.java | 144 +++++++
.../apache/paimon/management/PermissionAccess.java | 130 ++++++
.../paimon/management/PermissionAssignment.java | 191 +++++++++
.../paimon/management/PermissionColumns.java | 124 ++++++
.../paimon/management/PermissionManagement.java | 27 +-
.../paimon/management/PermissionResource.java | 204 +++++++++
.../org/apache/paimon/management/ResourceType.java | 35 +-
.../main/java/org/apache/paimon/rest/RESTApi.java | 54 +++
.../paimon/rest/RESTPermissionManagement.java | 54 +++
.../java/org/apache/paimon/rest/ResourcePaths.java | 17 +
.../rest/requests/GrantPermissionRequest.java | 104 +++++
.../rest/requests/RevokePermissionRequest.java | 72 ++++
.../rest/responses/ListPermissionsResponse.java | 76 ++++
.../management/PermissionManagementJsonTest.java | 475 +++++++++++++++++++++
.../paimon/rest/RESTPermissionManagementTest.java | 251 +++++++++++
.../requests/RequestJacksonCompatibilityTest.java | 2 +
.../java/org/apache/paimon/rest/RESTCatalog.java | 7 +
.../org/apache/paimon/rest/ResourcePathsTest.java | 8 +
18 files changed, 1940 insertions(+), 35 deletions(-)
diff --git
a/paimon-api/src/main/java/org/apache/paimon/management/ListPermissionsRequest.java
b/paimon-api/src/main/java/org/apache/paimon/management/ListPermissionsRequest.java
new file mode 100644
index 0000000000..67558e847a
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/management/ListPermissionsRequest.java
@@ -0,0 +1,144 @@
+/*
+ * 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;
+
+/** Exact resource or scope, principal, and pagination filters for permission
assignments. */
+@Experimental
+public class ListPermissionsRequest {
+
+ public static final int MAX_PAGE_SIZE = 1000;
+
+ private final PermissionResource resource;
+ @Nullable private final String principal;
+ @Nullable private final String access;
+ @Nullable private final String pageToken;
+ @Nullable private final Integer maxResults;
+
+ public ListPermissionsRequest(
+ ResourceType resourceType,
+ @Nullable String database,
+ @Nullable String table,
+ @Nullable String function,
+ @Nullable String view,
+ @Nullable String principal,
+ @Nullable String access,
+ @Nullable String pageToken,
+ @Nullable Integer maxResults) {
+ this.resource = exactResource(resourceType, database, table, function,
view);
+ if (!isBlank(principal)) {
+ PermissionAssignment.validatePrincipal(principal);
+ }
+ checkArgument(maxResults == null || maxResults > 0, "maxResults must
be greater than 0.");
+ checkArgument(
+ maxResults == null || maxResults <= MAX_PAGE_SIZE,
+ "maxResults must be at most %s.",
+ MAX_PAGE_SIZE);
+ this.principal = isBlank(principal) ? null : principal;
+ this.access = isBlank(access) ? null :
PermissionAccess.canonicalize(resource, access);
+ this.pageToken = pageToken;
+ this.maxResults = maxResults;
+ }
+
+ public ResourceType getResourceType() {
+ return resource.getType();
+ }
+
+ @Nullable
+ public String getDatabase() {
+ return resource.getDatabase();
+ }
+
+ @Nullable
+ public String getTable() {
+ return resource.getTable();
+ }
+
+ @Nullable
+ public String getFunction() {
+ return resource.getFunction();
+ }
+
+ @Nullable
+ public String getView() {
+ return resource.getView();
+ }
+
+ @Nullable
+ public String getPrincipal() {
+ return principal;
+ }
+
+ @Nullable
+ public String getAccess() {
+ return access;
+ }
+
+ @Nullable
+ public String getPageToken() {
+ return pageToken;
+ }
+
+ @Nullable
+ public Integer getMaxResults() {
+ return maxResults;
+ }
+
+ public PermissionResource resource() {
+ return resource;
+ }
+
+ public ListPermissionsRequest withPageToken(@Nullable String newPageToken)
{
+ return new ListPermissionsRequest(
+ resource.getType(),
+ resource.getDatabase(),
+ resource.getTable(),
+ resource.getFunction(),
+ resource.getView(),
+ principal,
+ access,
+ newPageToken,
+ maxResults);
+ }
+
+ private static PermissionResource exactResource(
+ ResourceType resourceType,
+ @Nullable String database,
+ @Nullable String table,
+ @Nullable String function,
+ @Nullable String view) {
+ checkNotNull(resourceType, "resourceType cannot be null");
+ try {
+ return new PermissionResource(resourceType, database, table,
function, view);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException(
+ "Permission listing requires an exact target resource: " +
e.getMessage(), e);
+ }
+ }
+
+ 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/PermissionAccess.java
b/paimon-api/src/main/java/org/apache/paimon/management/PermissionAccess.java
new file mode 100644
index 0000000000..742f6c0483
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/management/PermissionAccess.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.management;
+
+import org.apache.paimon.annotation.Experimental;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/** Built-in access names and validation for permission assignments. */
+@Experimental
+public final class PermissionAccess {
+
+ /** Maximum wire length supported by the portable permission storage
contract. */
+ public static final int MAX_LENGTH = 32;
+
+ public static final String ALL = "ALL";
+ public static final String CREATEDATABASE = "CREATEDATABASE";
+ public static final String DESCRIBE = "DESCRIBE";
+ public static final String ALTER = "ALTER";
+ public static final String DROP = "DROP";
+ public static final String CREATETABLE = "CREATETABLE";
+ public static final String CREATEFUNCTION = "CREATEFUNCTION";
+ public static final String CREATEVIEW = "CREATEVIEW";
+ public static final String LIST = "LIST";
+ public static final String SELECT = "SELECT";
+ public static final String UPDATE = "UPDATE";
+ public static final String GRANT = "GRANT";
+
+ private static final Map<ResourceType, Set<String>> BUILT_INS = builtIns();
+
+ private PermissionAccess() {}
+
+ public static String canonicalize(String access) {
+ checkArgument(access != null && !access.trim().isEmpty(), "access
cannot be empty.");
+ checkArgument(
+ access.length() <= MAX_LENGTH,
+ "access must contain at most %s characters.",
+ MAX_LENGTH);
+ String canonical = access.toUpperCase(Locale.ROOT);
+ checkArgument(
+ canonical.length() <= MAX_LENGTH,
+ "access must contain at most %s characters after
canonicalization.",
+ MAX_LENGTH);
+ if (BUILT_INS.values().stream().anyMatch(values ->
values.contains(canonical))) {
+ return canonical;
+ }
+ throw new IllegalArgumentException(String.format("Unknown access
'%s'.", canonical));
+ }
+
+ public static String canonicalize(PermissionResource resource, String
access) {
+ checkNotNull(resource, "resource cannot be null");
+ String canonical = canonicalize(access);
+ checkArgument(
+ BUILT_INS.get(resource.getType()).contains(canonical),
+ "Access '%s' is not valid for %s.",
+ canonical,
+ resource.getType());
+ return canonical;
+ }
+
+ public static Set<String> builtIns(ResourceType type) {
+ return BUILT_INS.get(checkNotNull(type, "resource type cannot be
null"));
+ }
+
+ private static Map<ResourceType, Set<String>> builtIns() {
+ Map<ResourceType, Set<String>> accesses = new
EnumMap<>(ResourceType.class);
+ accesses.put(ResourceType.CATALOG, values(ALL, ALTER, DROP, GRANT,
CREATEDATABASE));
+ accesses.put(
+ ResourceType.CATALOG_ALL,
+ values(
+ ALL,
+ DESCRIBE,
+ ALTER,
+ DROP,
+ GRANT,
+ CREATETABLE,
+ CREATEVIEW,
+ CREATEFUNCTION,
+ LIST,
+ SELECT,
+ UPDATE));
+ accesses.put(
+ ResourceType.DATABASE,
+ values(
+ ALL,
+ DESCRIBE,
+ ALTER,
+ DROP,
+ GRANT,
+ CREATETABLE,
+ CREATEVIEW,
+ CREATEFUNCTION,
+ LIST));
+ accesses.put(ResourceType.DATABASE_ALL, values(ALL, SELECT, UPDATE,
ALTER, DROP, GRANT));
+ accesses.put(ResourceType.TABLE, values(ALL, SELECT, UPDATE, ALTER,
DROP, GRANT));
+ accesses.put(ResourceType.COLUMN, values(SELECT));
+ accesses.put(ResourceType.VIEW, values(ALL, SELECT, ALTER, DROP,
GRANT));
+ accesses.put(ResourceType.FUNCTION, values(ALL, SELECT, ALTER, DROP,
GRANT));
+ return Collections.unmodifiableMap(accesses);
+ }
+
+ private static Set<String> values(String... accesses) {
+ return Collections.unmodifiableSet(new
HashSet<>(Arrays.asList(accesses)));
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/management/PermissionAssignment.java
b/paimon-api/src/main/java/org/apache/paimon/management/PermissionAssignment.java
new file mode 100644
index 0000000000..9744ce8386
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/management/PermissionAssignment.java
@@ -0,0 +1,191 @@
+/*
+ * 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 java.time.Instant;
+import java.time.format.DateTimeParseException;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/**
+ * Direct permission assignment on one exact resource or explicit descendant
scope.
+ *
+ * <p>A {@link ResourceType#COLUMN COLUMN} assignment carries one {@link
PermissionColumns} value.
+ * The column range is mutable assignment content rather than identity:
granting the same resource,
+ * access, and principal replaces the complete range.
+ *
+ * <p>{@code expireTime}, when present, is an exclusive authorization upper
bound evaluated against
+ * the server clock. At or after that instant, the assignment must not
authorize access, although an
+ * expired record may remain listable until cleanup.
+ */
+@Experimental
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class PermissionAssignment {
+
+ /** Maximum principal identifier length in the portable REST management
contract. */
+ public static final int MAX_PRINCIPAL_LENGTH = 128;
+
+ private static final String FIELD_RESOURCE = "resource";
+ private static final String FIELD_ACCESS = "access";
+ private static final String FIELD_PRINCIPAL = "principal";
+ private static final String FIELD_COLUMNS = "columns";
+ private static final String FIELD_EXPIRE_TIME = "expireTime";
+
+ @JsonProperty(FIELD_RESOURCE)
+ private final PermissionResource resource;
+
+ @JsonProperty(FIELD_ACCESS)
+ private final String access;
+
+ @JsonProperty(FIELD_PRINCIPAL)
+ private final String principal;
+
+ @Nullable
+ @JsonProperty(FIELD_COLUMNS)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private final PermissionColumns columns;
+
+ @Nullable
+ @JsonProperty(FIELD_EXPIRE_TIME)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private final String expireTime;
+
+ public PermissionAssignment(
+ @JsonProperty(FIELD_RESOURCE) PermissionResource resource,
+ @JsonProperty(FIELD_ACCESS) String access,
+ @JsonProperty(FIELD_PRINCIPAL) String principal,
+ @Nullable @JsonProperty(FIELD_COLUMNS) PermissionColumns columns,
+ @Nullable @JsonProperty(FIELD_EXPIRE_TIME) String expireTime) {
+ this(resource, access, principal, columns, expireTime, true);
+ }
+
+ public PermissionAssignment(
+ PermissionResource resource,
+ String access,
+ String principal,
+ @Nullable String expireTime) {
+ this(resource, access, principal, null, expireTime);
+ }
+
+ /** Jackson-only response constructor; request DTOs use the validated
public constructor. */
+ @JsonCreator
+ @ConstructorProperties({
+ FIELD_RESOURCE,
+ FIELD_ACCESS,
+ FIELD_PRINCIPAL,
+ FIELD_COLUMNS,
+ FIELD_EXPIRE_TIME
+ })
+ PermissionAssignment(
+ @JsonProperty(FIELD_RESOURCE) PermissionResource resource,
+ @JsonProperty(FIELD_ACCESS) String access,
+ @JsonProperty(FIELD_PRINCIPAL) String principal,
+ @Nullable @JsonProperty(FIELD_COLUMNS) PermissionColumns columns,
+ @Nullable @JsonProperty(FIELD_EXPIRE_TIME) Object expireTime) {
+ this(resource, access, principal, columns, (String) expireTime, false);
+ }
+
+ @JsonGetter(FIELD_RESOURCE)
+ public PermissionResource getResource() {
+ return resource;
+ }
+
+ @JsonGetter(FIELD_ACCESS)
+ public String getAccess() {
+ return access;
+ }
+
+ @JsonGetter(FIELD_PRINCIPAL)
+ public String getPrincipal() {
+ return principal;
+ }
+
+ /** Validates and returns an opaque principal identifier. */
+ public static String validatePrincipal(String principal) {
+ checkArgument(
+ principal != null && !principal.trim().isEmpty(), "principal
cannot be empty.");
+ checkArgument(
+ principal.length() <= MAX_PRINCIPAL_LENGTH,
+ "principal must contain at most %s characters.",
+ MAX_PRINCIPAL_LENGTH);
+ return principal;
+ }
+
+ /** Returns the selected or excluded column range for a COLUMN assignment.
*/
+ @Nullable
+ @JsonGetter(FIELD_COLUMNS)
+ public PermissionColumns getColumns() {
+ return columns;
+ }
+
+ /** Returns the exclusive authorization upper bound, or null for no
expiry. */
+ @Nullable
+ @JsonGetter(FIELD_EXPIRE_TIME)
+ public String getExpireTime() {
+ return expireTime;
+ }
+
+ private PermissionAssignment(
+ PermissionResource resource,
+ String access,
+ String principal,
+ @Nullable PermissionColumns columns,
+ @Nullable String expireTime,
+ boolean validate) {
+ this.resource = validate ? checkNotNull(resource, "resource cannot be
null") : resource;
+ this.access = validate ? PermissionAccess.canonicalize(resource,
access) : access;
+ this.principal = validate ? validatePrincipal(principal) : principal;
+ if (validate) {
+ checkArgument(
+ resource.getType() == ResourceType.COLUMN ? columns !=
null : columns == null,
+ resource.getType() == ResourceType.COLUMN
+ ? "columns is required for COLUMN resource."
+ : "columns is only valid for COLUMN resource.");
+ validateExpireTime(expireTime);
+ }
+ this.columns = columns;
+ this.expireTime = expireTime;
+ }
+
+ private static void validateExpireTime(@Nullable String expireTime) {
+ if (expireTime == null) {
+ return;
+ }
+ try {
+ Instant instant = Instant.parse(expireTime);
+ checkArgument(
+ instant.getNano() % 1_000_000 == 0,
+ "expireTime must have at most millisecond precision.");
+ } catch (DateTimeParseException e) {
+ throw new IllegalArgumentException("expireTime must be an ISO-8601
UTC instant.", e);
+ }
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/management/PermissionColumns.java
b/paimon-api/src/main/java/org/apache/paimon/management/PermissionColumns.java
new file mode 100644
index 0000000000..105d4fb575
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/management/PermissionColumns.java
@@ -0,0 +1,124 @@
+/*
+ * 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 java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/**
+ * Included or excluded top-level columns of a column-level permission
assignment.
+ *
+ * <p>Exactly one list is present. Included names form an allowlist; excluded
names form a denylist.
+ */
+@Experimental
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class PermissionColumns {
+
+ private static final String FIELD_COLUMN_NAMES = "columnNames";
+ private static final String FIELD_EXCLUDED_COLUMN_NAMES =
"excludedColumnNames";
+
+ @Nullable
+ @JsonProperty(FIELD_COLUMN_NAMES)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private final List<String> columnNames;
+
+ @Nullable
+ @JsonProperty(FIELD_EXCLUDED_COLUMN_NAMES)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private final List<String> excludedColumnNames;
+
+ @JsonCreator
+ @ConstructorProperties({FIELD_COLUMN_NAMES, FIELD_EXCLUDED_COLUMN_NAMES})
+ public PermissionColumns(
+ @Nullable @JsonProperty(FIELD_COLUMN_NAMES) List<String>
columnNames,
+ @Nullable @JsonProperty(FIELD_EXCLUDED_COLUMN_NAMES) List<String>
excludedColumnNames) {
+ checkArgument(
+ (columnNames == null) != (excludedColumnNames == null),
+ "columns must contain exactly one of columnNames or
excludedColumnNames.");
+ this.columnNames = immutableNonEmpty(columnNames, FIELD_COLUMN_NAMES);
+ this.excludedColumnNames =
+ immutableNonEmpty(excludedColumnNames,
FIELD_EXCLUDED_COLUMN_NAMES);
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_COLUMN_NAMES)
+ public List<String> getColumnNames() {
+ return columnNames;
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_EXCLUDED_COLUMN_NAMES)
+ public List<String> getExcludedColumnNames() {
+ return excludedColumnNames;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof PermissionColumns)) {
+ return false;
+ }
+ PermissionColumns that = (PermissionColumns) o;
+ return Objects.equals(columnNames, that.columnNames)
+ && Objects.equals(excludedColumnNames,
that.excludedColumnNames);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(columnNames, excludedColumnNames);
+ }
+
+ @Nullable
+ private static List<String> immutableNonEmpty(
+ @Nullable List<String> columns, String fieldName) {
+ if (columns == null) {
+ return null;
+ }
+ checkArgument(!columns.isEmpty(), "%s cannot be empty.", fieldName);
+ for (String column : columns) {
+ checkArgument(
+ column != null && !column.trim().isEmpty(),
+ "%s cannot contain an empty column name.",
+ fieldName);
+ }
+ checkArgument(
+ new HashSet<>(columns).size() == columns.size(),
+ "%s cannot contain duplicate column names.",
+ fieldName);
+ return Collections.unmodifiableList(new ArrayList<>(columns));
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java
b/paimon-api/src/main/java/org/apache/paimon/management/PermissionManagement.java
similarity index 51%
copy from
paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java
copy to
paimon-api/src/main/java/org/apache/paimon/management/PermissionManagement.java
index e3979c437a..3eb669c142 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java
+++
b/paimon-api/src/main/java/org/apache/paimon/management/PermissionManagement.java
@@ -16,27 +16,18 @@
* limitations under the License.
*/
-package org.apache.paimon.rest;
+package org.apache.paimon.management;
-import org.junit.Test;
+import org.apache.paimon.PagedList;
+import org.apache.paimon.annotation.Experimental;
-import static org.junit.jupiter.api.Assertions.assertEquals;
+/** Control-plane contract for managing permissions on catalog resources. */
+@Experimental
+public interface PermissionManagement {
-/** Test for {@link ResourcePaths}. */
-public class ResourcePathsTest {
+ PagedList<PermissionAssignment> listPermissions(ListPermissionsRequest
request);
- @Test
- public void testUrlEncode() {
- String database = "test_db";
- String objectName = "test_table$snapshot";
- ResourcePaths resourcePaths = new ResourcePaths("paimon");
- assertEquals(
- "/v1/paimon/databases/test_db/tables/test_table%24snapshot",
- resourcePaths.table(database, objectName));
+ void grantPermission(PermissionAssignment assignment);
- resourcePaths = new ResourcePaths("paimon/aaaa");
- assertEquals(
-
"/v1/paimon%2Faaaa/databases/test_db/tables/test_table%24snapshot",
- resourcePaths.table(database, objectName));
- }
+ void revokePermission(PermissionResource resource, String access, String
principal);
}
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
new file mode 100644
index 0000000000..f0100af100
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/management/PermissionResource.java
@@ -0,0 +1,204 @@
+/*
+ * 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 java.util.Objects;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/** Structured reference to a resource or explicit descendant scope inside the
REST catalog. */
+@Experimental
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class PermissionResource {
+
+ private static final String FIELD_TYPE = "type";
+ private static final String FIELD_DATABASE = "database";
+ private static final String FIELD_TABLE = "table";
+ private static final String FIELD_FUNCTION = "function";
+ private static final String FIELD_VIEW = "view";
+
+ @JsonProperty(FIELD_TYPE)
+ private final ResourceType type;
+
+ @Nullable
+ @JsonProperty(FIELD_DATABASE)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private final String database;
+
+ @Nullable
+ @JsonProperty(FIELD_TABLE)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private final String table;
+
+ @Nullable
+ @JsonProperty(FIELD_FUNCTION)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private final String function;
+
+ @Nullable
+ @JsonProperty(FIELD_VIEW)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private final String view;
+
+ @JsonCreator
+ @ConstructorProperties({FIELD_TYPE, FIELD_DATABASE, FIELD_TABLE,
FIELD_FUNCTION, FIELD_VIEW})
+ public PermissionResource(
+ @JsonProperty(FIELD_TYPE) String type,
+ @Nullable @JsonProperty(FIELD_DATABASE) String database,
+ @Nullable @JsonProperty(FIELD_TABLE) String table,
+ @Nullable @JsonProperty(FIELD_FUNCTION) String function,
+ @Nullable @JsonProperty(FIELD_VIEW) String view) {
+ this(ResourceType.fromString(type), database, table, function, view);
+ }
+
+ public PermissionResource(
+ ResourceType type,
+ @Nullable String database,
+ @Nullable String table,
+ @Nullable String function,
+ @Nullable String view) {
+ this.type = checkNotNull(type, "resource type cannot be null");
+ validate(type, database, table, function, view);
+ this.database = blankToNull(database);
+ this.table = blankToNull(table);
+ this.function = blankToNull(function);
+ this.view = blankToNull(view);
+ }
+
+ @JsonGetter(FIELD_TYPE)
+ public ResourceType getType() {
+ return type;
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_DATABASE)
+ public String getDatabase() {
+ return database;
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_TABLE)
+ public String getTable() {
+ return table;
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_FUNCTION)
+ public String getFunction() {
+ return function;
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_VIEW)
+ public String getView() {
+ return view;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof PermissionResource)) {
+ return false;
+ }
+ PermissionResource that = (PermissionResource) o;
+ return type == that.type
+ && Objects.equals(database, that.database)
+ && Objects.equals(table, that.table)
+ && Objects.equals(function, that.function)
+ && Objects.equals(view, that.view);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(type, database, table, function, view);
+ }
+
+ private static void validate(
+ ResourceType type,
+ @Nullable String database,
+ @Nullable String table,
+ @Nullable String function,
+ @Nullable String view) {
+ switch (type) {
+ case CATALOG:
+ case CATALOG_ALL:
+ checkArgument(
+ isBlank(database) && isBlank(table) &&
isBlank(function) && isBlank(view),
+ "%s resource cannot contain object identifiers.",
+ type);
+ break;
+ case DATABASE:
+ case DATABASE_ALL:
+ checkArgument(!isBlank(database), "database is required for %s
resource.", type);
+ checkArgument(
+ isBlank(table) && isBlank(function) && isBlank(view),
+ "%s resource cannot contain table, function, or view.",
+ type);
+ break;
+ case TABLE:
+ case COLUMN:
+ checkArgument(!isBlank(database), "database is required for %s
resource.", type);
+ checkArgument(!isBlank(table), "table is required for %s
resource.", type);
+ checkArgument(
+ isBlank(function) && isBlank(view),
+ "%s resource cannot contain function or view.",
+ type);
+ break;
+ case FUNCTION:
+ checkArgument(!isBlank(database), "database is required for
FUNCTION resource.");
+ checkArgument(!isBlank(function), "function is required for
FUNCTION resource.");
+ checkArgument(
+ isBlank(table) && isBlank(view),
+ "FUNCTION resource cannot contain table or view.");
+ break;
+ case VIEW:
+ checkArgument(!isBlank(database), "database is required for
VIEW resource.");
+ checkArgument(!isBlank(view), "view is required for VIEW
resource.");
+ checkArgument(
+ isBlank(table) && isBlank(function),
+ "VIEW resource cannot contain table or function.");
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported resource type
" + type);
+ }
+ }
+
+ private static boolean isBlank(@Nullable String value) {
+ return value == null || value.trim().isEmpty();
+ }
+
+ @Nullable
+ private static String blankToNull(@Nullable String value) {
+ return isBlank(value) ? null : value;
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java
b/paimon-api/src/main/java/org/apache/paimon/management/ResourceType.java
similarity index 51%
copy from
paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java
copy to paimon-api/src/main/java/org/apache/paimon/management/ResourceType.java
index e3979c437a..ef38471ef6 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java
+++ b/paimon-api/src/main/java/org/apache/paimon/management/ResourceType.java
@@ -16,27 +16,28 @@
* limitations under the License.
*/
-package org.apache.paimon.rest;
+package org.apache.paimon.management;
-import org.junit.Test;
+import org.apache.paimon.annotation.Experimental;
-import static org.junit.jupiter.api.Assertions.assertEquals;
+import javax.annotation.Nullable;
-/** Test for {@link ResourcePaths}. */
-public class ResourcePathsTest {
+import java.util.Locale;
- @Test
- public void testUrlEncode() {
- String database = "test_db";
- String objectName = "test_table$snapshot";
- ResourcePaths resourcePaths = new ResourcePaths("paimon");
- assertEquals(
- "/v1/paimon/databases/test_db/tables/test_table%24snapshot",
- resourcePaths.table(database, objectName));
+/** Resource types and explicit descendant scopes supported by permission
management. */
+@Experimental
+public enum ResourceType {
+ CATALOG,
+ CATALOG_ALL,
+ DATABASE,
+ DATABASE_ALL,
+ TABLE,
+ COLUMN,
+ VIEW,
+ FUNCTION;
- resourcePaths = new ResourcePaths("paimon/aaaa");
- assertEquals(
-
"/v1/paimon%2Faaaa/databases/test_db/tables/test_table%24snapshot",
- resourcePaths.table(database, objectName));
+ @Nullable
+ public static ResourceType fromString(@Nullable String value) {
+ return value == null ? null : valueOf(value.toUpperCase(Locale.ROOT));
}
}
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 238a3969ff..e2f5cc3702 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
@@ -20,11 +20,15 @@ package org.apache.paimon.rest;
import org.apache.paimon.PagedList;
import org.apache.paimon.Snapshot;
+import org.apache.paimon.annotation.Experimental;
import org.apache.paimon.annotation.Public;
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.ListPermissionsRequest;
+import org.apache.paimon.management.PermissionAssignment;
+import org.apache.paimon.management.PermissionResource;
import org.apache.paimon.options.Options;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.partition.PartitionStatistics;
@@ -48,6 +52,7 @@ 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.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;
@@ -55,6 +60,7 @@ import org.apache.paimon.rest.requests.RegisterTableRequest;
import org.apache.paimon.rest.requests.RenameTableRequest;
import org.apache.paimon.rest.requests.ReplaceTableRequest;
import org.apache.paimon.rest.requests.ResetConsumerRequest;
+import org.apache.paimon.rest.requests.RevokePermissionRequest;
import org.apache.paimon.rest.requests.RollbackSchemaRequest;
import org.apache.paimon.rest.requests.RollbackTableRequest;
import org.apache.paimon.rest.responses.AlterDatabaseResponse;
@@ -79,6 +85,7 @@ import
org.apache.paimon.rest.responses.ListFunctionDetailsResponse;
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.ListSnapshotsResponse;
import org.apache.paimon.rest.responses.ListTableDetailsResponse;
import org.apache.paimon.rest.responses.ListTablesGloballyResponse;
@@ -833,6 +840,46 @@ public class RESTApi {
restAuthFunction);
}
+ /** Lists permissions on an exact resource in the configured REST catalog.
*/
+ @Experimental
+ public ListPermissionsResponse listPermissions(ListPermissionsRequest
request) {
+ Map<String, String> queryParams = Maps.newHashMap();
+ putQueryParameter(queryParams, "resourceType",
request.getResourceType().name());
+ putQueryParameter(queryParams, "database", request.getDatabase());
+ putQueryParameter(queryParams, "table", request.getTable());
+ putQueryParameter(queryParams, "function", request.getFunction());
+ putQueryParameter(queryParams, "view", request.getView());
+ putQueryParameter(queryParams, "principal", request.getPrincipal());
+ putQueryParameter(queryParams, "access", request.getAccess());
+ if (request.getMaxResults() != null) {
+ queryParams.put(MAX_RESULTS, request.getMaxResults().toString());
+ }
+ putQueryParameter(queryParams, PAGE_TOKEN, request.getPageToken());
+ return client.get(
+ resourcePaths.permissions(),
+ queryParams,
+ ListPermissionsResponse.class,
+ restAuthFunction);
+ }
+
+ /** Grants a permission for the configured REST catalog. */
+ @Experimental
+ public void grantPermission(PermissionAssignment assignment) {
+ client.post(
+ resourcePaths.grantPermission(),
+ new GrantPermissionRequest(assignment),
+ restAuthFunction);
+ }
+
+ /** Revokes a permission for the configured REST catalog. */
+ @Experimental
+ public void revokePermission(PermissionResource resource, String access,
String principal) {
+ client.post(
+ resourcePaths.revokePermission(),
+ new RevokePermissionRequest(resource, access, principal),
+ restAuthFunction);
+ }
+
/**
* Drop table.
*
@@ -1727,4 +1774,11 @@ public class RESTApi {
RESTAuthFunction authFunction() {
return restAuthFunction;
}
+
+ private static void putQueryParameter(
+ Map<String, String> queryParams, String name, @Nullable String
value) {
+ if (StringUtils.isNotEmpty(value)) {
+ queryParams.put(name, value);
+ }
+ }
}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/RESTPermissionManagement.java
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTPermissionManagement.java
new file mode 100644
index 0000000000..3091d1ac09
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTPermissionManagement.java
@@ -0,0 +1,54 @@
+/*
+ * 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.ListPermissionsRequest;
+import org.apache.paimon.management.PermissionAssignment;
+import org.apache.paimon.management.PermissionManagement;
+import org.apache.paimon.management.PermissionResource;
+import org.apache.paimon.rest.responses.ListPermissionsResponse;
+
+/** REST implementation of permission management, bound to the configured REST
catalog prefix. */
+@Experimental
+public class RESTPermissionManagement implements PermissionManagement {
+
+ private final RESTApi api;
+
+ public RESTPermissionManagement(RESTApi api) {
+ this.api = api;
+ }
+
+ @Override
+ public PagedList<PermissionAssignment>
listPermissions(ListPermissionsRequest request) {
+ ListPermissionsResponse response = api.listPermissions(request);
+ return new PagedList<>(response.getPermissions(),
response.getNextPageToken());
+ }
+
+ @Override
+ public void grantPermission(PermissionAssignment assignment) {
+ api.grantPermission(assignment);
+ }
+
+ @Override
+ public void revokePermission(PermissionResource resource, String access,
String principal) {
+ api.revokePermission(resource, access, principal);
+ }
+}
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 ae09705467..6ae7ebf8c3 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
@@ -18,6 +18,7 @@
package org.apache.paimon.rest;
+import org.apache.paimon.annotation.Experimental;
import org.apache.paimon.options.Options;
import org.apache.paimon.shade.guava30.com.google.common.base.Joiner;
@@ -42,6 +43,7 @@ public class ResourcePaths {
protected static final String REGISTER = "register";
protected static final String FUNCTIONS = "functions";
protected static final String FUNCTION_DETAILS = "function-details";
+ protected static final String PERMISSIONS = "permissions";
protected static final String ID = "id";
private static final Joiner SLASH = Joiner.on("/").skipNulls();
@@ -60,6 +62,21 @@ public class ResourcePaths {
this.prefix = encodeString(prefix);
}
+ @Experimental
+ public String permissions() {
+ return SLASH.join(V1, prefix, PERMISSIONS);
+ }
+
+ @Experimental
+ public String grantPermission() {
+ return SLASH.join(permissions(), "grant");
+ }
+
+ @Experimental
+ public String revokePermission() {
+ return SLASH.join(permissions(), "revoke");
+ }
+
public String databases() {
return SLASH.join(V1, prefix, DATABASES);
}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/GrantPermissionRequest.java
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/GrantPermissionRequest.java
new file mode 100644
index 0000000000..377e997adb
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/GrantPermissionRequest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.PermissionColumns;
+import org.apache.paimon.management.PermissionResource;
+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;
+
+/** Request for granting or replacing a permission assignment. */
+@Experimental
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class GrantPermissionRequest implements RESTRequest {
+
+ private static final String FIELD_RESOURCE = "resource";
+ private static final String FIELD_ACCESS = "access";
+ private static final String FIELD_PRINCIPAL = "principal";
+ private static final String FIELD_COLUMNS = "columns";
+ private static final String FIELD_EXPIRE_TIME = "expireTime";
+
+ private final PermissionAssignment assignment;
+
+ public GrantPermissionRequest(PermissionAssignment assignment) {
+ this.assignment = assignment;
+ }
+
+ @JsonCreator
+ @ConstructorProperties({
+ FIELD_RESOURCE,
+ FIELD_ACCESS,
+ FIELD_PRINCIPAL,
+ FIELD_COLUMNS,
+ FIELD_EXPIRE_TIME
+ })
+ public GrantPermissionRequest(
+ @JsonProperty(FIELD_RESOURCE) PermissionResource resource,
+ @JsonProperty(FIELD_ACCESS) String access,
+ @JsonProperty(FIELD_PRINCIPAL) String principal,
+ @Nullable @JsonProperty(FIELD_COLUMNS) PermissionColumns columns,
+ @Nullable @JsonProperty(FIELD_EXPIRE_TIME) String expireTime) {
+ this.assignment =
+ new PermissionAssignment(resource, access, principal, columns,
expireTime);
+ }
+
+ public PermissionAssignment assignment() {
+ return assignment;
+ }
+
+ @JsonGetter(FIELD_RESOURCE)
+ public PermissionResource getResource() {
+ return assignment.getResource();
+ }
+
+ @JsonGetter(FIELD_ACCESS)
+ public String getAccess() {
+ return assignment.getAccess();
+ }
+
+ @JsonGetter(FIELD_PRINCIPAL)
+ public String getPrincipal() {
+ return assignment.getPrincipal();
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_COLUMNS)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public PermissionColumns getColumns() {
+ return assignment.getColumns();
+ }
+
+ @Nullable
+ @JsonGetter(FIELD_EXPIRE_TIME)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public String getExpireTime() {
+ return assignment.getExpireTime();
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/RevokePermissionRequest.java
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/RevokePermissionRequest.java
new file mode 100644
index 0000000000..0da87420fd
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/RevokePermissionRequest.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.requests;
+
+import org.apache.paimon.annotation.Experimental;
+import org.apache.paimon.management.PermissionAccess;
+import org.apache.paimon.management.PermissionResource;
+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.JsonProperty;
+
+import java.beans.ConstructorProperties;
+
+/** Idempotent request for revoking a permission assignment by identity. */
+@Experimental
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class RevokePermissionRequest implements RESTRequest {
+
+ private static final String FIELD_RESOURCE = "resource";
+ private static final String FIELD_ACCESS = "access";
+ private static final String FIELD_PRINCIPAL = "principal";
+
+ private final PermissionResource resource;
+ private final String access;
+ private final String principal;
+
+ @JsonCreator
+ @ConstructorProperties({FIELD_RESOURCE, FIELD_ACCESS, FIELD_PRINCIPAL})
+ public RevokePermissionRequest(
+ @JsonProperty(FIELD_RESOURCE) PermissionResource resource,
+ @JsonProperty(FIELD_ACCESS) String access,
+ @JsonProperty(FIELD_PRINCIPAL) String principal) {
+ this.resource = resource;
+ this.access = PermissionAccess.canonicalize(resource, access);
+ this.principal =
+
org.apache.paimon.management.PermissionAssignment.validatePrincipal(principal);
+ }
+
+ @JsonGetter(FIELD_RESOURCE)
+ public PermissionResource getResource() {
+ return resource;
+ }
+
+ @JsonGetter(FIELD_ACCESS)
+ public String getAccess() {
+ return access;
+ }
+
+ @JsonGetter(FIELD_PRINCIPAL)
+ public String getPrincipal() {
+ return principal;
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListPermissionsResponse.java
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListPermissionsResponse.java
new file mode 100644
index 0000000000..ee4d86d1ed
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListPermissionsResponse.java
@@ -0,0 +1,76 @@
+/*
+ * 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.PermissionAssignment;
+
+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 permissions. */
+@Experimental
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ListPermissionsResponse implements
PagedResponse<PermissionAssignment> {
+
+ private static final String FIELD_PERMISSIONS = "permissions";
+ private static final String FIELD_NEXT_PAGE_TOKEN = "nextPageToken";
+
+ @JsonProperty(FIELD_PERMISSIONS)
+ private final List<PermissionAssignment> permissions;
+
+ @Nullable
+ @JsonProperty(FIELD_NEXT_PAGE_TOKEN)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private final String nextPageToken;
+
+ @JsonCreator
+ @ConstructorProperties({FIELD_PERMISSIONS, FIELD_NEXT_PAGE_TOKEN})
+ public ListPermissionsResponse(
+ @JsonProperty(FIELD_PERMISSIONS) List<PermissionAssignment>
permissions,
+ @Nullable @JsonProperty(FIELD_NEXT_PAGE_TOKEN) String
nextPageToken) {
+ this.permissions = permissions;
+ this.nextPageToken = nextPageToken;
+ }
+
+ @JsonGetter(FIELD_PERMISSIONS)
+ public List<PermissionAssignment> getPermissions() {
+ return permissions;
+ }
+
+ @Override
+ @Nullable
+ @JsonGetter(FIELD_NEXT_PAGE_TOKEN)
+ public String getNextPageToken() {
+ return nextPageToken;
+ }
+
+ @Override
+ public List<PermissionAssignment> data() {
+ return permissions;
+ }
+}
diff --git
a/paimon-api/src/test/java/org/apache/paimon/management/PermissionManagementJsonTest.java
b/paimon-api/src/test/java/org/apache/paimon/management/PermissionManagementJsonTest.java
new file mode 100644
index 0000000000..f05e32a215
--- /dev/null
+++
b/paimon-api/src/test/java/org/apache/paimon/management/PermissionManagementJsonTest.java
@@ -0,0 +1,475 @@
+/*
+ * 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.GrantPermissionRequest;
+import org.apache.paimon.rest.requests.RevokePermissionRequest;
+import org.apache.paimon.rest.responses.ListPermissionsResponse;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+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 permission management contracts. */
+public class PermissionManagementJsonTest {
+
+ private static final String ASSIGNMENT_JSON =
+ "{\"resource\":{\"type\":\"TABLE\",\"database\":\"sales\","
+ + "\"table\":\"orders\"},\"access\":\"SELECT\","
+ + "\"principal\":\"analyst\","
+ + "\"expireTime\":\"2027-01-01T00:00:00Z\"}";
+
+ private static final String COLUMN_ASSIGNMENT_JSON =
+ "{\"resource\":{\"type\":\"COLUMN\",\"database\":\"sales\","
+ + "\"table\":\"orders\"},\"access\":\"SELECT\","
+ + "\"principal\":\"analyst\",\"columns\":{"
+ + "\"columnNames\":[\"id\",\"region\"]}}";
+
+ @Test
+ void testAssignmentDeserializesWithShadedAndExternalJackson() throws
Exception {
+ assertAssignment(RESTApi.fromJson(ASSIGNMENT_JSON,
PermissionAssignment.class));
+ assertAssignment(
+ new com.fasterxml.jackson.databind.ObjectMapper()
+ .readValue(ASSIGNMENT_JSON,
PermissionAssignment.class));
+ }
+
+ @Test
+ void testAssignmentDeserializesLowerCaseResourceType() throws Exception {
+ String lowerCaseJson = ASSIGNMENT_JSON.replace("\"TABLE\"",
"\"table\"");
+
+ assertAssignment(RESTApi.fromJson(lowerCaseJson,
PermissionAssignment.class));
+ assertAssignment(
+ new com.fasterxml.jackson.databind.ObjectMapper()
+ .readValue(lowerCaseJson, PermissionAssignment.class));
+ }
+
+ @Test
+ void testListResponseDoesNotApplyGrantValidation() throws Exception {
+ String preciseExpiry = "2027-01-01T00:00:00.123456Z";
+ String responseJson =
+ "{\"permissions\":["
+ + ASSIGNMENT_JSON.replace("2027-01-01T00:00:00Z",
preciseExpiry)
+ + "]}";
+
+ ListPermissionsResponse shaded =
+ RESTApi.fromJson(responseJson, ListPermissionsResponse.class);
+ ListPermissionsResponse external =
+ new com.fasterxml.jackson.databind.ObjectMapper()
+ .readValue(responseJson,
ListPermissionsResponse.class);
+
+
assertThat(shaded.getPermissions().get(0).getExpireTime()).isEqualTo(preciseExpiry);
+
assertThat(external.getPermissions().get(0).getExpireTime()).isEqualTo(preciseExpiry);
+ assertThatThrownBy(
+ () ->
+ new GrantPermissionRequest(
+ tableResource(), "SELECT", "analyst",
null, preciseExpiry))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("millisecond");
+ }
+
+ @Test
+ void testColumnAssignmentRoundTripsWithShadedAndExternalJackson() throws
Exception {
+ PermissionAssignment shaded =
+ RESTApi.fromJson(COLUMN_ASSIGNMENT_JSON,
PermissionAssignment.class);
+ PermissionAssignment external =
+ new com.fasterxml.jackson.databind.ObjectMapper()
+ .readValue(COLUMN_ASSIGNMENT_JSON,
PermissionAssignment.class);
+
+ for (PermissionAssignment assignment : Arrays.asList(shaded,
external)) {
+
assertThat(assignment.getResource().getType()).isEqualTo(ResourceType.COLUMN);
+ assertThat(assignment.getAccess()).isEqualTo("SELECT");
+
assertThat(assignment.getColumns().getColumnNames()).containsExactly("id",
"region");
+
assertThat(assignment.getColumns().getExcludedColumnNames()).isNull();
+ }
+
+ Map<?, ?> wire = RESTApi.fromJson(RESTApi.toJson(shaded), Map.class);
+ assertThat(((Map<?, ?>) wire.get("columns")).get("columnNames"))
+ .isEqualTo(Arrays.asList("id", "region"));
+ Map<?, ?> externalWire =
+ new com.fasterxml.jackson.databind.ObjectMapper()
+ .readValue(
+ new
com.fasterxml.jackson.databind.ObjectMapper()
+ .writeValueAsString(external),
+ Map.class);
+ Map<?, ?> externalColumns = (Map<?, ?>) externalWire.get("columns");
+
assertThat(externalColumns.get("columnNames")).isEqualTo(Arrays.asList("id",
"region"));
+ assertThat(externalColumns.get("excludedColumnNames")).isNull();
+ }
+
+ @Test
+ void testGrantAndRevokeUsePrivilegeOnlyWireShapes() throws Exception {
+ PermissionAssignment assignment =
+ RESTApi.fromJson(ASSIGNMENT_JSON, PermissionAssignment.class);
+ Map<?, ?> grant =
+ RESTApi.fromJson(RESTApi.toJson(new
GrantPermissionRequest(assignment)), Map.class);
+
+ assertThat(grant.get("access")).isEqualTo("SELECT");
+ assertThat(grant.containsKey("columns")).isFalse();
+ assertThat(grant.containsKey("policy")).isFalse();
+ assertThat(grant.containsKey("grantOption")).isFalse();
+
+ Map<?, ?> revoke =
+ RESTApi.fromJson(
+ RESTApi.toJson(
+ new RevokePermissionRequest(
+ assignment.getResource(),
+ assignment.getAccess(),
+ assignment.getPrincipal())),
+ Map.class);
+ assertThat(revoke.get("access")).isEqualTo("SELECT");
+ assertThat(revoke.containsKey("columns")).isFalse();
+ assertThat(revoke.containsKey("policy")).isFalse();
+ assertThat(revoke.containsKey("policyType")).isFalse();
+ assertThat(revoke.containsKey("grantOption")).isFalse();
+ assertThat(revoke.containsKey("expireTime")).isFalse();
+ }
+
+ @Test
+ void testPermissionRequestWithoutExpiry() throws Exception {
+ String permissionJson =
+ "{\"resource\":{\"type\":\"TABLE\",\"database\":\"sales\","
+ + "\"table\":\"orders\"},\"access\":\"select\","
+ + "\"principal\":\"analyst\"}";
+
+ assertThat(RESTApi.fromJson(permissionJson,
GrantPermissionRequest.class).getExpireTime())
+ .isNull();
+ assertThat(RESTApi.fromJson(permissionJson,
RevokePermissionRequest.class).getAccess())
+ .isEqualTo("SELECT");
+ }
+
+ @Test
+ void testAccessAndPermissionValidation() {
+ assertThat(
+ new PermissionAssignment(
+ catalogResource(), "createdatabase",
"analyst", null)
+ .getAccess())
+ .isEqualTo("CREATEDATABASE");
+ assertThat(
+ new PermissionAssignment(databaseResource(),
"createview", "analyst", null)
+ .getAccess())
+ .isEqualTo("CREATEVIEW");
+ assertThat(
+ new PermissionAssignment(functionResource(), "select",
"analyst", null)
+ .getAccess())
+ .isEqualTo("SELECT");
+ assertThat(PermissionAccess.builtIns(ResourceType.CATALOG))
+ .containsExactlyInAnyOrder("ALL", "ALTER", "DROP", "GRANT",
"CREATEDATABASE");
+ assertThat(PermissionAccess.builtIns(ResourceType.CATALOG_ALL))
+ .containsExactlyInAnyOrder(
+ "ALL",
+ "DESCRIBE",
+ "ALTER",
+ "DROP",
+ "GRANT",
+ "CREATETABLE",
+ "CREATEVIEW",
+ "CREATEFUNCTION",
+ "LIST",
+ "SELECT",
+ "UPDATE");
+ assertThat(PermissionAccess.builtIns(ResourceType.DATABASE))
+ .containsExactlyInAnyOrder(
+ "ALL",
+ "DESCRIBE",
+ "ALTER",
+ "DROP",
+ "GRANT",
+ "CREATETABLE",
+ "CREATEVIEW",
+ "CREATEFUNCTION",
+ "LIST");
+ assertThat(PermissionAccess.builtIns(ResourceType.DATABASE_ALL))
+ .containsExactlyInAnyOrder("ALL", "ALTER", "DROP", "SELECT",
"UPDATE", "GRANT");
+ assertThat(PermissionAccess.builtIns(ResourceType.TABLE))
+ .containsExactlyInAnyOrder("ALL", "ALTER", "DROP", "SELECT",
"UPDATE", "GRANT");
+ assertThat(PermissionAccess.builtIns(ResourceType.VIEW))
+ .containsExactlyInAnyOrder("ALL", "ALTER", "DROP", "SELECT",
"GRANT");
+ assertThat(PermissionAccess.builtIns(ResourceType.FUNCTION))
+ .containsExactlyInAnyOrder("ALL", "ALTER", "DROP", "SELECT",
"GRANT");
+
assertThat(PermissionAccess.builtIns(ResourceType.COLUMN)).containsExactly("SELECT");
+
+ PermissionColumns included = new PermissionColumns(Arrays.asList("id",
"region"), null);
+ assertThat(
+ new PermissionAssignment(
+ columnResource(), "select", "analyst",
included, null)
+ .getColumns())
+ .isEqualTo(included);
+
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ catalogResource(), "SELECT",
"analyst", null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("not valid for CATALOG");
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ databaseResource(), "SELECT",
"analyst", null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("not valid for DATABASE");
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ catalogAllResource(),
"CREATEDATABASE", "analyst", null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("not valid for CATALOG_ALL");
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ databaseAllResource(), "LIST",
"analyst", null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("not valid for DATABASE_ALL");
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ tableResource(), "CREATEVIEW",
"analyst", null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("not valid for TABLE");
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ functionResource(), "UPDATE",
"analyst", null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("not valid for FUNCTION");
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ columnResource(), "UPDATE", "analyst",
included, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("not valid for COLUMN");
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ columnResource(), "SELECT", "analyst",
null, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("columns is required");
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ tableResource(), "SELECT", "analyst",
included, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("only valid for COLUMN");
+ assertThatThrownBy(
+ () ->
+ new PermissionColumns(
+ Collections.singletonList("id"),
+ Collections.singletonList("region")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("exactly one");
+ assertThatThrownBy(() -> new
PermissionColumns(Collections.emptyList(), null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("cannot be empty");
+ assertThatThrownBy(() -> new PermissionColumns(Arrays.asList("id",
"id"), null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("duplicate");
+ assertThatThrownBy(() -> new
PermissionColumns(Collections.singletonList(" "), null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("empty");
+ for (String access :
+ Arrays.asList(
+ "USE_CATALOG",
+ "CREATE_DATABASE",
+ "USE_DATABASE",
+ "CREATE_TABLE",
+ "CREATE_VIEW",
+ "CREATE_FUNCTION",
+ "INSERT",
+ "DELETE",
+ "EXECUTE",
+ "MANAGE_PERMISSIONS",
+ "vendor.example/read_sensitive")) {
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ tableResource(), access,
"analyst", null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Unknown access");
+ }
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ tableResource(),
+ repeat('A',
PermissionAccess.MAX_LENGTH + 1),
+ "analyst",
+ null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("32");
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ tableResource(), "a/" + repeat('ß',
16), "analyst", null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("after canonicalization");
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ tableResource(),
+ "SELECT",
+ repeat('p',
PermissionAssignment.MAX_PRINCIPAL_LENGTH + 1),
+ null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("128");
+ assertThatThrownBy(
+ () ->
+ new PermissionAssignment(
+ tableResource(),
+ "SELECT",
+ "analyst",
+ "2027-01-01T00:00:00.000001Z"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("millisecond");
+ }
+
+ @Test
+ void testPermissionColumnsDefensivelyCopiesItsRange() {
+ java.util.List<String> source = new ArrayList<>(Arrays.asList("id",
"region"));
+ PermissionColumns columns = new PermissionColumns(source, null);
+
+ source.clear();
+ assertThat(columns.getColumnNames()).containsExactly("id", "region");
+ assertThatThrownBy(() -> columns.getColumnNames().add("email"))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ @Test
+ void testPermissionListRequiresExactResourceAndBoundsPageSize() {
+ assertThatThrownBy(
+ () ->
+ new ListPermissionsRequest(
+ ResourceType.TABLE,
+ "sales",
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 25))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("exact target");
+ assertThatThrownBy(
+ () ->
+ new ListPermissionsRequest(
+ ResourceType.CATALOG,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 1001))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("at most 1000");
+
+ ListPermissionsRequest databaseAccess =
+ new ListPermissionsRequest(
+ ResourceType.DATABASE,
+ "sales",
+ null,
+ null,
+ null,
+ null,
+ "createview",
+ null,
+ 25);
+ assertThat(databaseAccess.getAccess()).isEqualTo("CREATEVIEW");
+ assertThatThrownBy(
+ () ->
+ new ListPermissionsRequest(
+ ResourceType.DATABASE,
+ "sales",
+ null,
+ null,
+ null,
+ null,
+ "SELECT",
+ null,
+ 25))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("not valid for DATABASE");
+
+ assertThat(databaseAccess.withPageToken("
\t").getPageToken()).isEqualTo(" \t");
+ }
+
+ @Test
+ void testResourceCanonicalizesBlankIrrelevantLocators() throws Exception {
+ PermissionResource catalog =
+ new PermissionResource(ResourceType.CATALOG, "", " ", null,
null);
+ PermissionResource catalogAll =
+ new PermissionResource(ResourceType.CATALOG_ALL, "", " ",
null, null);
+
+ assertThat(catalog).isEqualTo(catalogResource());
+
assertThat(RESTApi.toJson(catalog)).isEqualTo("{\"type\":\"CATALOG\"}");
+ assertThat(catalogAll).isEqualTo(catalogAllResource());
+
assertThat(RESTApi.toJson(catalogAll)).isEqualTo("{\"type\":\"CATALOG_ALL\"}");
+ assertThat(RESTApi.toJson(databaseAllResource()))
+
.isEqualTo("{\"type\":\"DATABASE_ALL\",\"database\":\"sales\"}");
+ }
+
+ private static PermissionResource catalogResource() {
+ return new PermissionResource(ResourceType.CATALOG, null, null, null,
null);
+ }
+
+ private static PermissionResource catalogAllResource() {
+ return new PermissionResource(ResourceType.CATALOG_ALL, null, null,
null, null);
+ }
+
+ private static PermissionResource databaseResource() {
+ return new PermissionResource(ResourceType.DATABASE, "sales", null,
null, null);
+ }
+
+ private static PermissionResource databaseAllResource() {
+ return new PermissionResource(ResourceType.DATABASE_ALL, "sales",
null, null, null);
+ }
+
+ private static PermissionResource tableResource() {
+ return new PermissionResource(ResourceType.TABLE, "sales", "orders",
null, null);
+ }
+
+ private static PermissionResource columnResource() {
+ return new PermissionResource(ResourceType.COLUMN, "sales", "orders",
null, null);
+ }
+
+ private static PermissionResource functionResource() {
+ return new PermissionResource(ResourceType.FUNCTION, "sales", null,
"calculate_tax", null);
+ }
+
+ private static String repeat(char value, int length) {
+ char[] values = new char[length];
+ Arrays.fill(values, value);
+ return new String(values);
+ }
+
+ private static void assertAssignment(PermissionAssignment assignment) {
+
assertThat(assignment.getResource().getType()).isEqualTo(ResourceType.TABLE);
+ assertThat(assignment.getResource().getDatabase()).isEqualTo("sales");
+ assertThat(assignment.getResource().getTable()).isEqualTo("orders");
+ assertThat(assignment.getAccess()).isEqualTo("SELECT");
+ assertThat(assignment.getPrincipal()).isEqualTo("analyst");
+
assertThat(assignment.getExpireTime()).isEqualTo("2027-01-01T00:00:00Z");
+ }
+}
diff --git
a/paimon-api/src/test/java/org/apache/paimon/rest/RESTPermissionManagementTest.java
b/paimon-api/src/test/java/org/apache/paimon/rest/RESTPermissionManagementTest.java
new file mode 100644
index 0000000000..48d19ffa76
--- /dev/null
+++
b/paimon-api/src/test/java/org/apache/paimon/rest/RESTPermissionManagementTest.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.ListPermissionsRequest;
+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.ResourceType;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.rest.exceptions.ForbiddenException;
+
+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.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+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 permission management. */
+public class RESTPermissionManagementTest {
+
+ private static final String BASE_PATH = "/v1/catalog+id/permissions";
+
+ private HttpServer server;
+ private PermissionManagement management;
+ private final AtomicReference<String> grantBody = new AtomicReference<>();
+ private final AtomicReference<String> revokeBody = new AtomicReference<>();
+ private final AtomicReference<String> authorization = new
AtomicReference<>();
+ private final AtomicReference<String> listQuery = new AtomicReference<>();
+ private final AtomicInteger revokeCalls = new AtomicInteger();
+
+ @BeforeEach
+ void setUp() throws Exception {
+ server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.createContext(
+ "/v1/",
+ exchange -> {
+
authorization.set(exchange.getRequestHeaders().getFirst("Authorization"));
+ String path = exchange.getRequestURI().getRawPath();
+ if (BASE_PATH.equals(path) &&
"GET".equals(exchange.getRequestMethod())) {
+ listQuery.set(exchange.getRequestURI().getRawQuery());
+ respond(
+ exchange,
+ 200,
+
"{\"permissions\":[{\"resource\":{\"type\":\"TABLE\","
+ +
"\"database\":\"sales\",\"table\":\"orders\"},"
+ + "\"access\":\"SELECT\","
+ + "\"principal\":\"analyst\"}],"
+ + "\"nextPageToken\":\"next\"}");
+ } else if ((BASE_PATH + "/grant").equals(path)) {
+ String body = readBody(exchange);
+ grantBody.set(body);
+ if (body.contains("denied")) {
+ respond(exchange, 403,
"{\"message\":\"forbidden\",\"code\":403}");
+ } else {
+ respond(exchange, 200, null);
+ }
+ } else if ((BASE_PATH + "/revoke").equals(path)) {
+ revokeBody.set(readBody(exchange));
+ revokeCalls.incrementAndGet();
+ respond(exchange, 200, null);
+ } 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 RESTPermissionManagement(new RESTApi(options, false));
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void testListUsesEncodedPrefixAndCompleteFilters() throws Exception {
+ PagedList<PermissionAssignment> page =
+ management.listPermissions(
+ new ListPermissionsRequest(
+ ResourceType.TABLE,
+ "sales",
+ "orders",
+ null,
+ null,
+ "analyst",
+ null,
+ "start",
+ 25));
+
+ assertThat(page.getElements()).hasSize(1);
+
assertThat(page.getElements().get(0).getPrincipal()).isEqualTo("analyst");
+ assertThat(page.getNextPageToken()).isEqualTo("next");
+ assertThat(queryParameters(listQuery.get()))
+ .containsEntry("principal", "analyst")
+ .containsEntry("resourceType", "TABLE")
+ .containsEntry("database", "sales")
+ .containsEntry("table", "orders")
+ .containsEntry("maxResults", "25")
+ .containsEntry("pageToken", "start");
+ assertThat(authorization.get()).isEqualTo("Bearer secret");
+ }
+
+ @Test
+ void testGrantAndRevokeUseStructuredWireShapes() throws Exception {
+ PermissionAssignment assignment = assignment("analyst");
+ management.grantPermission(assignment);
+ management.revokePermission(assignment.getResource(), "select",
assignment.getPrincipal());
+
+ Map<?, ?> grant = RESTApi.fromJson(grantBody.get(), Map.class);
+ Map<?, ?> grantResource = (Map<?, ?>) grant.get("resource");
+ assertThat(grantResource.get("type")).isEqualTo("TABLE");
+ assertThat(grantResource.get("database")).isEqualTo("sales");
+ assertThat(grantResource.get("table")).isEqualTo("orders");
+ assertThat(grant.get("principal")).isEqualTo("analyst");
+ assertThat(grant.containsKey("columns")).isFalse();
+ assertThat(grant.containsKey("policy")).isFalse();
+ assertThat(grant.containsKey("grantOption")).isFalse();
+ assertThat(grant.containsKey("catalog")).isFalse();
+
+ Map<?, ?> revoke = RESTApi.fromJson(revokeBody.get(), Map.class);
+ Map<?, ?> revokeResource = (Map<?, ?>) revoke.get("resource");
+ assertThat(revokeResource.get("type")).isEqualTo("TABLE");
+ assertThat(revokeResource.get("database")).isEqualTo("sales");
+ assertThat(revokeResource.get("table")).isEqualTo("orders");
+ assertThat(revoke.get("access")).isEqualTo("SELECT");
+ assertThat(revoke.get("principal")).isEqualTo("analyst");
+ assertThat(revoke.containsKey("expireTime")).isFalse();
+ assertThat(revoke.containsKey("grantOption")).isFalse();
+ }
+
+ @Test
+ void testForbiddenGrantPreservesRESTErrorTranslation() {
+ assertThatThrownBy(() ->
management.grantPermission(assignment("denied")))
+ .isInstanceOf(ForbiddenException.class)
+ .hasMessageContaining("forbidden");
+ }
+
+ @Test
+ void testColumnGrantCarriesRangeButRevokeUsesOnlyIdentity() throws
Exception {
+ PermissionAssignment assignment =
+ new PermissionAssignment(
+ new PermissionResource(ResourceType.COLUMN, "sales",
"orders", null, null),
+ "SELECT",
+ "analyst",
+ new PermissionColumns(Arrays.asList("id", "region"),
null),
+ null);
+
+ management.grantPermission(assignment);
+ Map<?, ?> grant = RESTApi.fromJson(grantBody.get(), Map.class);
+ assertThat(((Map<?, ?>) grant.get("columns")).get("columnNames"))
+ .isEqualTo(Arrays.asList("id", "region"));
+
+ management.revokePermission(
+ assignment.getResource(), assignment.getAccess(),
assignment.getPrincipal());
+ Map<?, ?> revoke = RESTApi.fromJson(revokeBody.get(), Map.class);
+ assertThat(((Map<?, ?>)
revoke.get("resource")).get("type")).isEqualTo("COLUMN");
+ assertThat(revoke.containsKey("columns")).isFalse();
+ }
+
+ @Test
+ void testRepeatedRevokeIsIdempotent() {
+ PermissionAssignment assignment = assignment("missing");
+ management.revokePermission(
+ assignment.getResource(), assignment.getAccess(),
assignment.getPrincipal());
+ management.revokePermission(
+ assignment.getResource(), assignment.getAccess(),
assignment.getPrincipal());
+
+ assertThat(revokeCalls).hasValue(2);
+ }
+
+ private static PermissionAssignment assignment(String principal) {
+ return new PermissionAssignment(
+ new PermissionResource(ResourceType.TABLE, "sales", "orders",
null, null),
+ "SELECT",
+ principal,
+ null);
+ }
+
+ private static Map<String, String> queryParameters(String query) throws
Exception {
+ Map<String, String> values = new HashMap<>();
+ for (String parameter : query.split("&")) {
+ String[] pair = parameter.split("=", 2);
+ values.put(URLDecoder.decode(pair[0], "UTF-8"),
URLDecoder.decode(pair[1], "UTF-8"));
+ }
+ return values;
+ }
+
+ 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 51bf7c8831..30ebab5463 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,9 +169,11 @@ public class RequestJacksonCompatibilityTest {
CreatePartitionsRequest.class,
CreateTableRequest.class,
CreateViewRequest.class,
+ GrantPermissionRequest.class,
RegisterTableRequest.class,
RenameTableRequest.class,
ReplaceTableRequest.class,
+ RevokePermissionRequest.class,
RollbackTableRequest.class)
.collect(Collectors.toSet());
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 a24fb23549..3c668aeacb 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
@@ -22,6 +22,7 @@ import org.apache.paimon.CoreOptions;
import org.apache.paimon.PagedList;
import org.apache.paimon.Snapshot;
import org.apache.paimon.TableType;
+import org.apache.paimon.annotation.Experimental;
import org.apache.paimon.annotation.VisibleForTesting;
import org.apache.paimon.catalog.Catalog;
import org.apache.paimon.catalog.CatalogContext;
@@ -39,6 +40,7 @@ import org.apache.paimon.fs.cache.CachingFileIO;
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.options.Options;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.partition.PartitionStatistics;
@@ -137,6 +139,11 @@ public class RESTCatalog implements Catalog {
return new RESTCatalogLoader(context);
}
+ @Experimental
+ public PermissionManagement permissionManagement() {
+ return new RESTPermissionManagement(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 e3979c437a..432ea8f776 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
@@ -39,4 +39,12 @@ public class ResourcePathsTest {
"/v1/paimon%2Faaaa/databases/test_db/tables/test_table%24snapshot",
resourcePaths.table(database, objectName));
}
+
+ @Test
+ public void testPermissionManagementUsesPrefix() {
+ ResourcePaths resourcePaths = new ResourcePaths("catalog/id");
+ assertEquals("/v1/catalog%2Fid/permissions",
resourcePaths.permissions());
+ assertEquals("/v1/catalog%2Fid/permissions/grant",
resourcePaths.grantPermission());
+ assertEquals("/v1/catalog%2Fid/permissions/revoke",
resourcePaths.revokePermission());
+ }
}