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());
+    }
 }

Reply via email to