This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 6311a012 feat(rest): add permission management APIs (#820)
6311a012 is described below

commit 6311a012b6436ff91cfc6730bfa8f2a5974bc694
Author: Jiajia Li <[email protected]>
AuthorDate: Mon Sep 14 18:06:42 2026 +0800

    feat(rest): add permission management APIs (#820)
---
 crates/paimon/src/api/api_request.rs           |   53 +
 crates/paimon/src/api/api_response.rs          |   38 +
 crates/paimon/src/api/management.rs            | 1298 ++++++++++++++++++++++++
 crates/paimon/src/api/mod.rs                   |   13 +-
 crates/paimon/src/api/resource_paths.rs        |   24 +
 crates/paimon/src/api/rest_api.rs              |   48 +-
 crates/paimon/src/catalog/rest/rest_catalog.rs |   28 +
 crates/paimon/tests/mock_server.rs             |  176 +++-
 crates/paimon/tests/rest_api_test.rs           |  265 ++++-
 crates/paimon/tests/rest_catalog_test.rs       |   28 +
 10 files changed, 1953 insertions(+), 18 deletions(-)

diff --git a/crates/paimon/src/api/api_request.rs 
b/crates/paimon/src/api/api_request.rs
index 48646757..8c721fdf 100644
--- a/crates/paimon/src/api/api_request.rs
+++ b/crates/paimon/src/api/api_request.rs
@@ -22,6 +22,7 @@
 use serde::{Deserialize, Deserializer, Serialize};
 use std::collections::HashMap;
 
+use crate::api::management::{PermissionAccess, PermissionAssignment, 
PermissionResource};
 use crate::{
     catalog::{Function, FunctionDefinition, Identifier, ViewSchema},
     spec::{DataField, PartitionStatistics, Schema, SchemaChange},
@@ -311,6 +312,28 @@ impl AuthTableQueryRequest {
     }
 }
 
+/// Body of `POST {prefix}/permissions/revoke`: the assignment identity only. 
Revoking a
+/// `COLUMN` assignment removes its whole column range, so no `columns` travel 
here.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RevokePermissionRequest {
+    pub resource: PermissionResource,
+    pub access: String,
+    pub principal: String,
+}
+
+impl RevokePermissionRequest {
+    pub fn new(resource: PermissionResource, access: &str, principal: &str) -> 
crate::Result<Self> {
+        let access = 
PermissionAccess::canonicalize_for(resource.resource_type(), access)?;
+        PermissionAssignment::validate_principal(principal)?;
+        Ok(Self {
+            resource: resource.canonicalized()?,
+            access,
+            principal: principal.to_string(),
+        })
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -349,6 +372,36 @@ mod tests {
         assert_eq!(serde_json::to_string(&req).unwrap(), "{}");
     }
 
+    #[test]
+    fn 
test_revoke_permission_request_canonicalizes_access_and_carries_only_the_identity()
 {
+        let request = RevokePermissionRequest::new(
+            PermissionResource::table("sales", "orders"),
+            "select",
+            "analyst",
+        )
+        .unwrap();
+        assert_eq!(
+            serde_json::to_string(&request).unwrap(),
+            
r#"{"resource":{"type":"TABLE","database":"sales","table":"orders"},"access":"SELECT","principal":"analyst"}"#
+        );
+        let blank_view: PermissionResource = serde_json::from_str(
+            
r#"{"type":"TABLE","database":"sales","table":"orders","view":""}"#,
+        )
+        .unwrap();
+        let request = RevokePermissionRequest::new(blank_view, "select", 
"analyst").unwrap();
+        assert_eq!(
+            serde_json::to_string(&request).unwrap(),
+            
r#"{"resource":{"type":"TABLE","database":"sales","table":"orders"},"access":"SELECT","principal":"analyst"}"#
+        );
+        let error =
+            RevokePermissionRequest::new(PermissionResource::catalog(), 
"select", "analyst")
+                .unwrap_err();
+        assert!(
+            error.to_string().contains("not valid for CATALOG"),
+            "{error}"
+        );
+    }
+
     #[test]
     fn test_create_partitions_request_serialization() {
         let req = CreatePartitionsRequest::new(
diff --git a/crates/paimon/src/api/api_response.rs 
b/crates/paimon/src/api/api_response.rs
index 9d68cf3d..ce62a392 100644
--- a/crates/paimon/src/api/api_response.rs
+++ b/crates/paimon/src/api/api_response.rs
@@ -22,6 +22,7 @@
 use serde::{Deserialize, Deserializer, Serialize};
 use std::collections::HashMap;
 
+use crate::api::management::PermissionAssignment;
 use crate::catalog::{Function, FunctionDefinition, ViewSchema};
 use crate::spec::{DataField, Schema};
 
@@ -493,6 +494,25 @@ impl AuthTableQueryResponse {
     }
 }
 
+/// Response of `GET {prefix}/permissions`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ListPermissionsResponse {
+    #[serde(default)]
+    pub permissions: Vec<PermissionAssignment>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub next_page_token: Option<String>,
+}
+
+impl ListPermissionsResponse {
+    pub fn new(permissions: Vec<PermissionAssignment>, next_page_token: 
Option<String>) -> Self {
+        Self {
+            permissions,
+            next_page_token,
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -633,6 +653,24 @@ mod tests {
         assert!(blank.is_unrestricted());
     }
 
+    #[test]
+    fn test_list_permissions_response_deserialization() {
+        let response: ListPermissionsResponse = serde_json::from_str(
+            
r#"{"permissions":[{"resource":{"type":"TABLE","database":"sales","table":"orders"},"access":"SELECT","principal":"analyst"}],"nextPageToken":"next"}"#,
+        )
+        .unwrap();
+        assert_eq!(response.permissions.len(), 1);
+        assert_eq!(response.permissions[0].principal(), "analyst");
+        assert_eq!(response.next_page_token.as_deref(), Some("next"));
+        let last: ListPermissionsResponse = 
serde_json::from_str("{}").unwrap();
+        assert!(last.permissions.is_empty());
+        assert_eq!(last.next_page_token, None);
+        assert_eq!(
+            serde_json::to_string(&ListPermissionsResponse::new(vec![], 
None)).unwrap(),
+            r#"{"permissions":[]}"#
+        );
+    }
+
     #[test]
     fn test_error_response_serialization() {
         let resp = ErrorResponse::new(
diff --git a/crates/paimon/src/api/management.rs 
b/crates/paimon/src/api/management.rs
new file mode 100644
index 00000000..188ba370
--- /dev/null
+++ b/crates/paimon/src/api/management.rs
@@ -0,0 +1,1298 @@
+// 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.
+
+//! REST management API model: permission assignments and (later) data 
policies.
+//!
+//! Mirrors Java `org.apache.paimon.management`, whose request-side 
constructors store a
+//! corrected value rather than only checking it; `Deserialize` does neither, 
so a server may
+//! list values a client could not have sent. The send paths therefore run 
`canonicalized` on
+//! the types whose Java constructor corrects, `validate` on the types whose 
Java constructor
+//! only checks, and send what comes back.
+
+use std::fmt;
+use std::str::FromStr;
+
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+use crate::api::rest_error::RestError;
+use crate::{Error, Result};
+
+/// Client-side validation failures map to this: a real server answers 400 for 
the same input.
+pub(crate) fn bad_request(message: impl Into<String>) -> Error {
+    Error::RestApi {
+        source: RestError::BadRequest {
+            message: message.into(),
+        },
+    }
+}
+
+/// Java's `String.trim()` strips only characters `<= U+0020`, so a value is 
blank exactly when
+/// every character is one of those; `str::trim` would also strip the rest of 
Unicode whitespace.
+pub(crate) fn is_blank(value: &str) -> bool {
+    value.chars().all(|ch| ch <= ' ')
+}
+
+/// Java's `String.length()` counts UTF-16 code units, and the wire limits are 
written against it.
+pub(crate) fn utf16_len(value: &str) -> usize {
+    value.encode_utf16().count()
+}
+
+fn blank_to_none(value: Option<String>) -> Option<String> {
+    value.filter(|value| !is_blank(value))
+}
+
+/// The kind of object a permission is attached to (Java `ResourceType`).
+///
+/// `CATALOG_ALL` / `DATABASE_ALL` scope over a catalog's or database's 
descendants, now and
+/// later; every other type names one exact object.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum ResourceType {
+    Catalog,
+    CatalogAll,
+    Database,
+    DatabaseAll,
+    Table,
+    Column,
+    View,
+    Function,
+}
+
+impl ResourceType {
+    pub const VALUES: [ResourceType; 8] = [
+        ResourceType::Catalog,
+        ResourceType::CatalogAll,
+        ResourceType::Database,
+        ResourceType::DatabaseAll,
+        ResourceType::Table,
+        ResourceType::Column,
+        ResourceType::View,
+        ResourceType::Function,
+    ];
+
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            ResourceType::Catalog => "CATALOG",
+            ResourceType::CatalogAll => "CATALOG_ALL",
+            ResourceType::Database => "DATABASE",
+            ResourceType::DatabaseAll => "DATABASE_ALL",
+            ResourceType::Table => "TABLE",
+            ResourceType::Column => "COLUMN",
+            ResourceType::View => "VIEW",
+            ResourceType::Function => "FUNCTION",
+        }
+    }
+}
+
+impl fmt::Display for ResourceType {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(self.as_str())
+    }
+}
+
+impl FromStr for ResourceType {
+    type Err = Error;
+
+    /// Case-insensitive, like Java `ResourceType.fromString`.
+    fn from_str(value: &str) -> Result<Self> {
+        let upper = value.to_uppercase();
+        Self::VALUES
+            .into_iter()
+            .find(|resource_type| resource_type.as_str() == upper)
+            .ok_or_else(|| bad_request(format!("Unknown resource type 
'{value}'.")))
+    }
+}
+
+impl Serialize for ResourceType {
+    fn serialize<S: Serializer>(&self, serializer: S) -> 
std::result::Result<S::Ok, S::Error> {
+        serializer.serialize_str(self.as_str())
+    }
+}
+
+impl<'de> Deserialize<'de> for ResourceType {
+    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> 
std::result::Result<Self, D::Error> {
+        String::deserialize(deserializer)?
+            .parse()
+            .map_err(serde::de::Error::custom)
+    }
+}
+
+/// A permission target: a resource type and the locators it needs (Java 
`PermissionResource`).
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PermissionResource {
+    #[serde(rename = "type")]
+    resource_type: ResourceType,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    database: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    table: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    function: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    view: Option<String>,
+}
+
+impl PermissionResource {
+    pub fn catalog() -> Self {
+        Self::unchecked(ResourceType::Catalog, None, None, None, None)
+    }
+
+    pub fn catalog_all() -> Self {
+        Self::unchecked(ResourceType::CatalogAll, None, None, None, None)
+    }
+
+    pub fn database(database: impl Into<String>) -> Self {
+        Self::unchecked(
+            ResourceType::Database,
+            Some(database.into()),
+            None,
+            None,
+            None,
+        )
+    }
+
+    pub fn database_all(database: impl Into<String>) -> Self {
+        Self::unchecked(
+            ResourceType::DatabaseAll,
+            Some(database.into()),
+            None,
+            None,
+            None,
+        )
+    }
+
+    pub fn table(database: impl Into<String>, table: impl Into<String>) -> 
Self {
+        Self::unchecked(
+            ResourceType::Table,
+            Some(database.into()),
+            Some(table.into()),
+            None,
+            None,
+        )
+    }
+
+    /// The columns of one table; the column range travels in the assignment.
+    pub fn column(database: impl Into<String>, table: impl Into<String>) -> 
Self {
+        Self::unchecked(
+            ResourceType::Column,
+            Some(database.into()),
+            Some(table.into()),
+            None,
+            None,
+        )
+    }
+
+    pub fn function(database: impl Into<String>, function: impl Into<String>) 
-> Self {
+        Self::unchecked(
+            ResourceType::Function,
+            Some(database.into()),
+            None,
+            Some(function.into()),
+            None,
+        )
+    }
+
+    pub fn view(database: impl Into<String>, view: impl Into<String>) -> Self {
+        Self::unchecked(
+            ResourceType::View,
+            Some(database.into()),
+            None,
+            None,
+            Some(view.into()),
+        )
+    }
+
+    pub fn new(
+        resource_type: ResourceType,
+        database: Option<&str>,
+        table: Option<&str>,
+        function: Option<&str>,
+        view: Option<&str>,
+    ) -> Result<Self> {
+        Self::unchecked(
+            resource_type,
+            database.map(str::to_string),
+            table.map(str::to_string),
+            function.map(str::to_string),
+            view.map(str::to_string),
+        )
+        .canonicalized()
+    }
+
+    /// Blank locators count as absent (Java `blankToNull`); what remains must 
be exactly the
+    /// locators the type needs.
+    pub fn canonicalized(mut self) -> Result<Self> {
+        self.database = blank_to_none(self.database);
+        self.table = blank_to_none(self.table);
+        self.function = blank_to_none(self.function);
+        self.view = blank_to_none(self.view);
+        self.check_locators()?;
+        Ok(self)
+    }
+
+    fn check_locators(&self) -> Result<()> {
+        let resource_type = self.resource_type;
+        let (needs_database, needs_table, needs_function, needs_view) = match 
resource_type {
+            ResourceType::Catalog | ResourceType::CatalogAll => (false, false, 
false, false),
+            ResourceType::Database | ResourceType::DatabaseAll => (true, 
false, false, false),
+            ResourceType::Table | ResourceType::Column => (true, true, false, 
false),
+            ResourceType::Function => (true, false, true, false),
+            ResourceType::View => (true, false, false, true),
+        };
+        for (present, needed, name) in [
+            (self.database.is_some(), needs_database, "database"),
+            (self.table.is_some(), needs_table, "table"),
+            (self.function.is_some(), needs_function, "function"),
+            (self.view.is_some(), needs_view, "view"),
+        ] {
+            if needed && !present {
+                return Err(bad_request(format!(
+                    "{name} is required for {resource_type} resource."
+                )));
+            }
+            if !needed && present {
+                return Err(bad_request(format!(
+                    "{resource_type} resource cannot contain {name}."
+                )));
+            }
+        }
+        Ok(())
+    }
+
+    fn unchecked(
+        resource_type: ResourceType,
+        database: Option<String>,
+        table: Option<String>,
+        function: Option<String>,
+        view: Option<String>,
+    ) -> Self {
+        Self {
+            resource_type,
+            database,
+            table,
+            function,
+            view,
+        }
+    }
+
+    pub fn resource_type(&self) -> ResourceType {
+        self.resource_type
+    }
+
+    /// The locator getters carry a `_name` suffix because the plain names 
belong to the
+    /// constructors above; Rust allows only one inherent item per name.
+    pub fn database_name(&self) -> Option<&str> {
+        self.database.as_deref()
+    }
+
+    pub fn table_name(&self) -> Option<&str> {
+        self.table.as_deref()
+    }
+
+    pub fn function_name(&self) -> Option<&str> {
+        self.function.as_deref()
+    }
+
+    pub fn view_name(&self) -> Option<&str> {
+        self.view.as_deref()
+    }
+}
+
+/// The built-in access names and their canonical (upper-case) form (Java 
`PermissionAccess`).
+pub struct PermissionAccess;
+
+impl PermissionAccess {
+    /// Maximum access-name length, in UTF-16 code units.
+    pub const MAX_LENGTH: usize = 32;
+    pub const ALL: &'static str = "ALL";
+    pub const CREATEDATABASE: &'static str = "CREATEDATABASE";
+    pub const DESCRIBE: &'static str = "DESCRIBE";
+    pub const ALTER: &'static str = "ALTER";
+    pub const DROP: &'static str = "DROP";
+    pub const CREATETABLE: &'static str = "CREATETABLE";
+    pub const CREATEFUNCTION: &'static str = "CREATEFUNCTION";
+    pub const CREATEVIEW: &'static str = "CREATEVIEW";
+    pub const LIST: &'static str = "LIST";
+    pub const SELECT: &'static str = "SELECT";
+    pub const UPDATE: &'static str = "UPDATE";
+    pub const GRANT: &'static str = "GRANT";
+
+    pub fn built_ins(resource_type: ResourceType) -> &'static [&'static str] {
+        match resource_type {
+            ResourceType::Catalog => &[
+                Self::ALL,
+                Self::ALTER,
+                Self::DROP,
+                Self::GRANT,
+                Self::CREATEDATABASE,
+            ],
+            ResourceType::CatalogAll => &[
+                Self::ALL,
+                Self::DESCRIBE,
+                Self::ALTER,
+                Self::DROP,
+                Self::GRANT,
+                Self::CREATETABLE,
+                Self::CREATEVIEW,
+                Self::CREATEFUNCTION,
+                Self::LIST,
+                Self::SELECT,
+                Self::UPDATE,
+            ],
+            ResourceType::Database => &[
+                Self::ALL,
+                Self::DESCRIBE,
+                Self::ALTER,
+                Self::DROP,
+                Self::GRANT,
+                Self::CREATETABLE,
+                Self::CREATEVIEW,
+                Self::CREATEFUNCTION,
+                Self::LIST,
+            ],
+            ResourceType::DatabaseAll | ResourceType::Table => &[
+                Self::ALL,
+                Self::SELECT,
+                Self::UPDATE,
+                Self::ALTER,
+                Self::DROP,
+                Self::GRANT,
+            ],
+            ResourceType::Column => &[Self::SELECT],
+            ResourceType::View | ResourceType::Function => &[
+                Self::ALL,
+                Self::SELECT,
+                Self::ALTER,
+                Self::DROP,
+                Self::GRANT,
+            ],
+        }
+    }
+
+    /// Upper-cases `access`; the length is checked again afterwards because 
upper-casing can
+    /// grow a string ("ß" -> "SS").
+    pub fn canonicalize(access: &str) -> Result<String> {
+        if is_blank(access) {
+            return Err(bad_request("access cannot be empty."));
+        }
+        if utf16_len(access) > Self::MAX_LENGTH {
+            return Err(bad_request(format!(
+                "access must contain at most {} characters.",
+                Self::MAX_LENGTH
+            )));
+        }
+        let canonical = access.to_uppercase();
+        if utf16_len(&canonical) > Self::MAX_LENGTH {
+            return Err(bad_request(format!(
+                "access must contain at most {} characters after 
canonicalization.",
+                Self::MAX_LENGTH
+            )));
+        }
+        let known = ResourceType::VALUES
+            .into_iter()
+            .any(|resource_type| 
Self::built_ins(resource_type).contains(&canonical.as_str()));
+        if !known {
+            return Err(bad_request(format!("Unknown access '{canonical}'.")));
+        }
+        Ok(canonical)
+    }
+
+    pub fn canonicalize_for(resource_type: ResourceType, access: &str) -> 
Result<String> {
+        let canonical = Self::canonicalize(access)?;
+        if !Self::built_ins(resource_type).contains(&canonical.as_str()) {
+            return Err(bad_request(format!(
+                "Access '{canonical}' is not valid for {resource_type}."
+            )));
+        }
+        Ok(canonical)
+    }
+}
+
+/// The column range of a `COLUMN` assignment: an allowlist (`columnNames`) or 
a denylist
+/// (`excludedColumnNames`), never both (Java `PermissionColumns`). Names are 
top-level fields;
+/// the allowlist denies columns added later, the denylist allows them.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PermissionColumns {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    column_names: Option<Vec<String>>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    excluded_column_names: Option<Vec<String>>,
+}
+
+impl PermissionColumns {
+    pub fn names(column_names: Vec<String>) -> Result<Self> {
+        Self::check(&column_names, "columnNames")?;
+        Ok(Self {
+            column_names: Some(column_names),
+            excluded_column_names: None,
+        })
+    }
+
+    pub fn excluded(excluded_column_names: Vec<String>) -> Result<Self> {
+        Self::check(&excluded_column_names, "excludedColumnNames")?;
+        Ok(Self {
+            column_names: None,
+            excluded_column_names: Some(excluded_column_names),
+        })
+    }
+
+    /// The constructors' rules: exactly one list, non-empty, no blank or 
duplicate name.
+    pub fn validate(&self) -> Result<()> {
+        match (&self.column_names, &self.excluded_column_names) {
+            (Some(columns), None) => Self::check(columns, "columnNames"),
+            (None, Some(columns)) => Self::check(columns, 
"excludedColumnNames"),
+            _ => Err(bad_request(
+                "columns must contain exactly one of columnNames or 
excludedColumnNames.",
+            )),
+        }
+    }
+
+    pub fn column_names(&self) -> Option<&[String]> {
+        self.column_names.as_deref()
+    }
+
+    pub fn excluded_column_names(&self) -> Option<&[String]> {
+        self.excluded_column_names.as_deref()
+    }
+
+    fn check(columns: &[String], field: &str) -> Result<()> {
+        if columns.is_empty() {
+            return Err(bad_request(format!("{field} cannot be empty.")));
+        }
+        if columns.iter().any(|column| is_blank(column)) {
+            return Err(bad_request(format!(
+                "{field} cannot contain an empty column name."
+            )));
+        }
+        let unique: std::collections::HashSet<&String> = 
columns.iter().collect();
+        if unique.len() != columns.len() {
+            return Err(bad_request(format!(
+                "{field} cannot contain duplicate column names."
+            )));
+        }
+        Ok(())
+    }
+}
+
+/// One access granted to one principal on one resource (Java 
`PermissionAssignment`).
+///
+/// The identity is `(resource, access, principal)`; `expire_time` is an 
exclusive upper bound
+/// on the server clock.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PermissionAssignment {
+    resource: PermissionResource,
+    access: String,
+    principal: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    columns: Option<PermissionColumns>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    expire_time: Option<String>,
+}
+
+impl PermissionAssignment {
+    /// Maximum principal length, in UTF-16 code units.
+    pub const MAX_PRINCIPAL_LENGTH: usize = 128;
+
+    pub fn new(
+        resource: PermissionResource,
+        access: &str,
+        principal: &str,
+        columns: Option<PermissionColumns>,
+        expire_time: Option<&str>,
+    ) -> Result<Self> {
+        Self {
+            resource,
+            access: access.to_string(),
+            principal: principal.to_string(),
+            columns,
+            expire_time: expire_time.map(str::to_string),
+        }
+        .canonicalized()
+    }
+
+    pub fn canonicalized(mut self) -> Result<Self> {
+        self.access =
+            PermissionAccess::canonicalize_for(self.resource.resource_type(), 
&self.access)?;
+        Self::validate_principal(&self.principal)?;
+        self.resource = self.resource.canonicalized()?;
+        match (
+            self.resource.resource_type() == ResourceType::Column,
+            self.columns.is_some(),
+        ) {
+            (true, false) => return Err(bad_request("columns is required for 
COLUMN resource.")),
+            (false, true) => return Err(bad_request("columns is only valid for 
COLUMN resource.")),
+            _ => {}
+        }
+        if let Some(columns) = &self.columns {
+            columns.validate()?;
+        }
+        if let Some(expire_time) = &self.expire_time {
+            validate_expire_time(expire_time)?;
+        }
+        Ok(self)
+    }
+
+    /// Principals are opaque, server-defined strings: only blankness and 
length are checked.
+    pub fn validate_principal(principal: &str) -> Result<()> {
+        if is_blank(principal) {
+            return Err(bad_request("principal cannot be empty."));
+        }
+        if utf16_len(principal) > Self::MAX_PRINCIPAL_LENGTH {
+            return Err(bad_request(format!(
+                "principal must contain at most {} characters.",
+                Self::MAX_PRINCIPAL_LENGTH
+            )));
+        }
+        Ok(())
+    }
+
+    pub fn resource(&self) -> &PermissionResource {
+        &self.resource
+    }
+
+    pub fn access(&self) -> &str {
+        &self.access
+    }
+
+    pub fn principal(&self) -> &str {
+        &self.principal
+    }
+
+    pub fn columns(&self) -> Option<&PermissionColumns> {
+        self.columns.as_ref()
+    }
+
+    pub fn expire_time(&self) -> Option<&str> {
+        self.expire_time.as_deref()
+    }
+}
+
+fn validate_expire_time(expire_time: &str) -> Result<()> {
+    // Java parses with `Instant.parse` (ISO_INSTANT, case-insensitive), which 
takes the `Z`
+    // offset and no other, so an offset RFC-3339 allows here the server would 
still refuse.
+    let instant = chrono::DateTime::parse_from_rfc3339(expire_time)
+        .ok()
+        .filter(|_| expire_time.ends_with(['Z', 'z']))
+        .ok_or_else(|| bad_request("expireTime must be an ISO-8601 UTC 
instant."))?;
+    if instant.timestamp_subsec_nanos() % 1_000_000 != 0 {
+        return Err(bad_request(
+            "expireTime must have at most millisecond precision.",
+        ));
+    }
+    Ok(())
+}
+
+pub(crate) fn validate_max_results(max_results: u32) -> Result<()> {
+    if max_results == 0 || max_results > ListPermissionsRequest::MAX_PAGE_SIZE 
{
+        return Err(bad_request(format!(
+            "maxResults must be between 1 and {}.",
+            ListPermissionsRequest::MAX_PAGE_SIZE
+        )));
+    }
+    Ok(())
+}
+
+/// Filters for `GET {prefix}/permissions` (Java `ListPermissionsRequest`).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ListPermissionsRequest {
+    pub resource: PermissionResource,
+    pub principal: Option<String>,
+    pub access: Option<String>,
+    /// Page size, `1..=MAX_PAGE_SIZE`.
+    pub max_results: Option<u32>,
+    /// Opaque continuation token from the previous page.
+    pub page_token: Option<String>,
+}
+
+impl ListPermissionsRequest {
+    /// Largest page a server has to honour.
+    pub const MAX_PAGE_SIZE: u32 = 1000;
+
+    pub fn new(resource: PermissionResource) -> Self {
+        Self {
+            resource,
+            principal: None,
+            access: None,
+            max_results: None,
+            page_token: None,
+        }
+    }
+
+    /// The query string as `(name, value)` pairs, in the order Java sends 
them.
+    pub fn query_params(&self) -> Result<Vec<(&'static str, String)>> {
+        let resource = self.resource.clone().canonicalized()?;
+        let resource_type = resource.resource_type();
+        let mut params = vec![("resourceType", resource_type.to_string())];
+        for (name, value) in [
+            ("database", resource.database_name()),
+            ("table", resource.table_name()),
+            ("function", resource.function_name()),
+            ("view", resource.view_name()),
+        ] {
+            if let Some(value) = value {
+                params.push((name, value.to_string()));
+            }
+        }
+        if let Some(principal) = self.principal.as_deref().filter(|value| 
!is_blank(value)) {
+            PermissionAssignment::validate_principal(principal)?;
+            params.push(("principal", principal.to_string()));
+        }
+        if let Some(access) = self.access.as_deref().filter(|value| 
!is_blank(value)) {
+            params.push((
+                "access",
+                PermissionAccess::canonicalize_for(resource_type, access)?,
+            ));
+        }
+        if let Some(max_results) = self.max_results {
+            validate_max_results(max_results)?;
+            params.push(("maxResults", max_results.to_string()));
+        }
+        if let Some(page_token) = self.page_token.as_deref().filter(|value| 
!value.is_empty()) {
+            params.push(("pageToken", page_token.to_string()));
+        }
+        Ok(params)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn table_resource() -> PermissionResource {
+        PermissionResource::table("sales", "orders")
+    }
+
+    #[test]
+    fn 
test_resource_type_wire_names_are_upper_snake_and_read_case_insensitively() {
+        assert_eq!(
+            serde_json::to_string(&ResourceType::CatalogAll).unwrap(),
+            r#""CATALOG_ALL""#
+        );
+        assert_eq!(
+            serde_json::from_str::<ResourceType>(r#""database_all""#).unwrap(),
+            ResourceType::DatabaseAll
+        );
+        assert_eq!("view".parse::<ResourceType>().unwrap(), 
ResourceType::View);
+        assert!("SCHEMA".parse::<ResourceType>().is_err());
+        assert_eq!(ResourceType::Function.to_string(), "FUNCTION");
+    }
+
+    #[test]
+    fn test_resource_serialization_omits_absent_locators() {
+        assert_eq!(
+            serde_json::to_string(&PermissionResource::catalog()).unwrap(),
+            r#"{"type":"CATALOG"}"#
+        );
+        assert_eq!(
+            serde_json::to_string(&PermissionResource::catalog_all()).unwrap(),
+            r#"{"type":"CATALOG_ALL"}"#
+        );
+        assert_eq!(
+            
serde_json::to_string(&PermissionResource::database_all("sales")).unwrap(),
+            r#"{"type":"DATABASE_ALL","database":"sales"}"#
+        );
+        assert_eq!(
+            serde_json::to_string(&table_resource()).unwrap(),
+            r#"{"type":"TABLE","database":"sales","table":"orders"}"#
+        );
+        assert_eq!(
+            serde_json::to_string(&PermissionResource::function("sales", 
"calculate_tax")).unwrap(),
+            
r#"{"type":"FUNCTION","database":"sales","function":"calculate_tax"}"#
+        );
+        assert_eq!(
+            serde_json::to_string(&PermissionResource::view("sales", 
"daily_orders")).unwrap(),
+            r#"{"type":"VIEW","database":"sales","view":"daily_orders"}"#
+        );
+        let parsed: PermissionResource = serde_json::from_str(
+            
r#"{"type":"table","database":"sales","table":"orders","extra":1}"#,
+        )
+        .unwrap();
+        assert_eq!(parsed, table_resource());
+    }
+
+    #[test]
+    fn 
test_resource_new_canonicalizes_blank_locators_and_enforces_the_type_rules() {
+        assert_eq!(
+            PermissionResource::new(ResourceType::Catalog, Some(""), Some(" 
"), None, None)
+                .unwrap(),
+            PermissionResource::catalog()
+        );
+        assert_eq!(
+            PermissionResource::new(
+                ResourceType::Function,
+                Some("sales"),
+                None,
+                Some("calculate_tax"),
+                None
+            )
+            .unwrap(),
+            PermissionResource::function("sales", "calculate_tax")
+        );
+        let message = |result: Result<PermissionResource>| 
result.unwrap_err().to_string();
+        assert!(message(PermissionResource::new(
+            ResourceType::Catalog,
+            Some("sales"),
+            None,
+            None,
+            None
+        ))
+        .contains("CATALOG resource cannot contain database"));
+        assert!(message(PermissionResource::new(
+            ResourceType::Database,
+            None,
+            None,
+            None,
+            None
+        ))
+        .contains("database is required for DATABASE"));
+        assert!(message(PermissionResource::new(
+            ResourceType::Table,
+            Some("sales"),
+            None,
+            None,
+            None
+        ))
+        .contains("table is required for TABLE"));
+        assert!(message(PermissionResource::new(
+            ResourceType::Column,
+            Some("sales"),
+            Some("orders"),
+            Some("f"),
+            None
+        ))
+        .contains("COLUMN resource cannot contain function"));
+        assert!(message(PermissionResource::new(
+            ResourceType::Function,
+            Some("sales"),
+            None,
+            None,
+            Some("v")
+        ))
+        .contains("function is required for FUNCTION"));
+        assert!(message(PermissionResource::new(
+            ResourceType::View,
+            Some("sales"),
+            Some("orders"),
+            None,
+            Some("v")
+        ))
+        .contains("VIEW resource cannot contain table"));
+    }
+
+    #[test]
+    fn test_access_built_ins_match_java() {
+        let sorted = |resource_type| {
+            let mut values = 
PermissionAccess::built_ins(resource_type).to_vec();
+            values.sort_unstable();
+            values
+        };
+        assert_eq!(
+            sorted(ResourceType::Catalog),
+            ["ALL", "ALTER", "CREATEDATABASE", "DROP", "GRANT"]
+        );
+        assert_eq!(
+            sorted(ResourceType::CatalogAll),
+            [
+                "ALL",
+                "ALTER",
+                "CREATEFUNCTION",
+                "CREATETABLE",
+                "CREATEVIEW",
+                "DESCRIBE",
+                "DROP",
+                "GRANT",
+                "LIST",
+                "SELECT",
+                "UPDATE"
+            ]
+        );
+        assert_eq!(
+            sorted(ResourceType::Database),
+            [
+                "ALL",
+                "ALTER",
+                "CREATEFUNCTION",
+                "CREATETABLE",
+                "CREATEVIEW",
+                "DESCRIBE",
+                "DROP",
+                "GRANT",
+                "LIST"
+            ]
+        );
+        assert_eq!(
+            sorted(ResourceType::DatabaseAll),
+            ["ALL", "ALTER", "DROP", "GRANT", "SELECT", "UPDATE"]
+        );
+        assert_eq!(
+            sorted(ResourceType::Table),
+            ["ALL", "ALTER", "DROP", "GRANT", "SELECT", "UPDATE"]
+        );
+        assert_eq!(
+            sorted(ResourceType::View),
+            ["ALL", "ALTER", "DROP", "GRANT", "SELECT"]
+        );
+        assert_eq!(
+            sorted(ResourceType::Function),
+            ["ALL", "ALTER", "DROP", "GRANT", "SELECT"]
+        );
+        assert_eq!(sorted(ResourceType::Column), ["SELECT"]);
+    }
+
+    #[test]
+    fn test_access_canonicalization() {
+        assert_eq!(
+            PermissionAccess::canonicalize("createdatabase").unwrap(),
+            "CREATEDATABASE"
+        );
+        assert_eq!(
+            PermissionAccess::canonicalize_for(ResourceType::Database, 
"createview").unwrap(),
+            "CREATEVIEW"
+        );
+        let message = |result: Result<String>| result.unwrap_err().to_string();
+        assert!(message(PermissionAccess::canonicalize(" ")).contains("access 
cannot be empty"));
+        assert!(message(PermissionAccess::canonicalize_for(
+            ResourceType::Catalog,
+            "SELECT"
+        ))
+        .contains("Access 'SELECT' is not valid for CATALOG"));
+        assert!(message(PermissionAccess::canonicalize_for(
+            ResourceType::CatalogAll,
+            "CREATEDATABASE"
+        ))
+        .contains("not valid for CATALOG_ALL"));
+        assert!(message(PermissionAccess::canonicalize_for(
+            ResourceType::DatabaseAll,
+            "LIST"
+        ))
+        .contains("not valid for DATABASE_ALL"));
+        for access in [
+            "USE_CATALOG",
+            "CREATE_DATABASE",
+            "USE_DATABASE",
+            "CREATE_TABLE",
+            "CREATE_VIEW",
+            "CREATE_FUNCTION",
+            "INSERT",
+            "DELETE",
+            "EXECUTE",
+            "MANAGE_PERMISSIONS",
+            "vendor.example/read_sensitive",
+        ] {
+            assert!(
+                
message(PermissionAccess::canonicalize(access)).contains("Unknown access"),
+                "{access}"
+            );
+        }
+        
assert!(message(PermissionAccess::canonicalize(&"A".repeat(33))).contains("32"));
+        // 18 code units before upper-casing, 34 after.
+        assert!(message(PermissionAccess::canonicalize(&format!(
+            "a/{}",
+            "ß".repeat(16)
+        )))
+        .contains("after canonicalization"));
+    }
+
+    const ASSIGNMENT_JSON: &str = 
r#"{"resource":{"type":"TABLE","database":"sales","table":"orders"},"access":"SELECT","principal":"analyst","expireTime":"2027-01-01T00:00:00Z"}"#;
+    const COLUMN_ASSIGNMENT_JSON: &str = 
r#"{"resource":{"type":"COLUMN","database":"sales","table":"orders"},"access":"SELECT","principal":"analyst","columns":{"columnNames":["id","region"]}}"#;
+
+    #[test]
+    fn test_assignment_round_trips_java_wire_json() {
+        let assignment: PermissionAssignment = 
serde_json::from_str(ASSIGNMENT_JSON).unwrap();
+        assert_eq!(assignment.resource(), &table_resource());
+        assert_eq!(assignment.access(), "SELECT");
+        assert_eq!(assignment.principal(), "analyst");
+        assert_eq!(assignment.expire_time(), Some("2027-01-01T00:00:00Z"));
+        assert_eq!(assignment.columns(), None);
+        assert_eq!(serde_json::to_string(&assignment).unwrap(), 
ASSIGNMENT_JSON);
+        let lower: PermissionAssignment =
+            serde_json::from_str(&ASSIGNMENT_JSON.replace("\"TABLE\"", 
"\"table\"")).unwrap();
+        assert_eq!(lower, assignment);
+        let built = PermissionAssignment::new(
+            table_resource(),
+            "select",
+            "analyst",
+            None,
+            Some("2027-01-01T00:00:00Z"),
+        )
+        .unwrap();
+        assert_eq!(built, assignment);
+    }
+
+    #[test]
+    fn test_column_assignment_round_trips_java_wire_json() {
+        let assignment: PermissionAssignment =
+            serde_json::from_str(COLUMN_ASSIGNMENT_JSON).unwrap();
+        assert_eq!(assignment.resource().resource_type(), 
ResourceType::Column);
+        let columns = assignment.columns().unwrap();
+        assert_eq!(
+            columns.column_names(),
+            Some(&["id".to_string(), "region".to_string()][..])
+        );
+        assert_eq!(columns.excluded_column_names(), None);
+        assert_eq!(
+            serde_json::to_string(&assignment).unwrap(),
+            COLUMN_ASSIGNMENT_JSON
+        );
+        let excluded = 
PermissionColumns::excluded(vec!["email".to_string()]).unwrap();
+        assert_eq!(
+            serde_json::to_string(&excluded).unwrap(),
+            r#"{"excludedColumnNames":["email"]}"#
+        );
+    }
+
+    #[test]
+    fn test_responses_are_not_validated_but_requests_are() {
+        let precise =
+            ASSIGNMENT_JSON.replace("2027-01-01T00:00:00Z", 
"2027-01-01T00:00:00.123456Z");
+        let listed: PermissionAssignment = 
serde_json::from_str(&precise).unwrap();
+        assert_eq!(listed.expire_time(), Some("2027-01-01T00:00:00.123456Z"));
+        let message = |expire_time: &str| {
+            PermissionAssignment::new(
+                table_resource(),
+                "SELECT",
+                "analyst",
+                None,
+                Some(expire_time),
+            )
+            .unwrap_err()
+            .to_string()
+        };
+        
assert!(message("2027-01-01T00:00:00.123456Z").contains("millisecond"));
+        
assert!(message("2027-01-01T00:00:00.000001Z").contains("millisecond"));
+        assert!(message("tomorrow").contains("ISO-8601"));
+        assert!(message("2027-01-01T00:00:00+08:00").contains("ISO-8601"));
+        for expire_time in ["2027-01-01T00:00:00Z", 
"2027-01-01T00:00:00.123Z"] {
+            assert!(
+                PermissionAssignment::new(
+                    table_resource(),
+                    "SELECT",
+                    "analyst",
+                    None,
+                    Some(expire_time),
+                )
+                .is_ok(),
+                "{expire_time}"
+            );
+        }
+    }
+
+    #[test]
+    fn test_assignment_validation_matches_java() {
+        let included =
+            PermissionColumns::names(vec!["id".to_string(), 
"region".to_string()]).unwrap();
+        let column_resource = PermissionResource::column("sales", "orders");
+        assert_eq!(
+            PermissionAssignment::new(
+                column_resource.clone(),
+                "select",
+                "analyst",
+                Some(included.clone()),
+                None
+            )
+            .unwrap()
+            .columns(),
+            Some(&included)
+        );
+        assert_eq!(
+            PermissionAssignment::new(
+                PermissionResource::catalog(),
+                "createdatabase",
+                "analyst",
+                None,
+                None
+            )
+            .unwrap()
+            .access(),
+            "CREATEDATABASE"
+        );
+        let rejects = |resource: PermissionResource,
+                       access: &str,
+                       principal: &str,
+                       columns: Option<PermissionColumns>,
+                       needle: &str| {
+            let message = PermissionAssignment::new(resource, access, 
principal, columns, None)
+                .unwrap_err()
+                .to_string();
+            assert!(
+                message.contains(needle),
+                "{message:?} should mention {needle:?}"
+            );
+        };
+        rejects(
+            PermissionResource::catalog(),
+            "SELECT",
+            "analyst",
+            None,
+            "not valid for CATALOG",
+        );
+        rejects(
+            PermissionResource::database("sales"),
+            "SELECT",
+            "analyst",
+            None,
+            "not valid for DATABASE",
+        );
+        rejects(
+            table_resource(),
+            "CREATEVIEW",
+            "analyst",
+            None,
+            "not valid for TABLE",
+        );
+        rejects(
+            PermissionResource::function("sales", "calculate_tax"),
+            "UPDATE",
+            "analyst",
+            None,
+            "not valid for FUNCTION",
+        );
+        rejects(
+            column_resource.clone(),
+            "UPDATE",
+            "analyst",
+            Some(included.clone()),
+            "not valid for COLUMN",
+        );
+        rejects(
+            column_resource,
+            "SELECT",
+            "analyst",
+            None,
+            "columns is required",
+        );
+        rejects(
+            table_resource(),
+            "SELECT",
+            "analyst",
+            Some(included),
+            "only valid for COLUMN",
+        );
+        rejects(
+            table_resource(),
+            "SELECT",
+            " ",
+            None,
+            "principal cannot be empty",
+        );
+        rejects(table_resource(), "SELECT", &"p".repeat(129), None, "128");
+        
assert!(PermissionAssignment::validate_principal(&"p".repeat(128)).is_ok());
+    }
+
+    #[test]
+    fn test_column_range_rules() {
+        let message = |result: Result<PermissionColumns>| 
result.unwrap_err().to_string();
+        
assert!(message(PermissionColumns::names(vec![])).contains("columnNames cannot 
be empty"));
+        assert!(message(PermissionColumns::excluded(vec![]))
+            .contains("excludedColumnNames cannot be empty"));
+        assert!(message(PermissionColumns::names(vec![
+            "id".to_string(),
+            "id".to_string()
+        ]))
+        .contains("duplicate"));
+        assert!(message(PermissionColumns::excluded(vec![" ".to_string()]))
+            .contains("empty column name"));
+    }
+
+    #[test]
+    fn 
test_assignment_rejects_a_column_range_no_constructor_could_have_built() {
+        let neither = 
serde_json::from_str::<PermissionColumns>(r#"{}"#).unwrap();
+        let both = serde_json::from_str::<PermissionColumns>(
+            r#"{"columnNames":["id"],"excludedColumnNames":["email"]}"#,
+        )
+        .unwrap();
+        let assign = |columns: PermissionColumns| {
+            PermissionAssignment::new(
+                PermissionResource::column("sales", "orders"),
+                "SELECT",
+                "analyst",
+                Some(columns),
+                None,
+            )
+        };
+        for columns in [neither, both] {
+            let message = assign(columns).unwrap_err().to_string();
+            assert!(message.contains("exactly one"), "{message:?}");
+        }
+        
assert!(assign(PermissionColumns::names(vec!["id".to_string()]).unwrap()).is_ok());
+    }
+
+    #[test]
+    fn test_send_paths_reject_values_no_constructor_could_have_built() {
+        let blank_table = PermissionResource::table("sales", "");
+        let message = |error: Error| error.to_string();
+        assert!(message(blank_table.clone().canonicalized().unwrap_err())
+            .contains("table is required for TABLE"));
+        assert!(message(
+            PermissionResource::database(" ")
+                .canonicalized()
+                .unwrap_err()
+        )
+        .contains("database is required for DATABASE"));
+        assert!(table_resource().canonicalized().is_ok());
+        assert!(ListPermissionsRequest::new(blank_table.clone())
+            .query_params()
+            .unwrap_err()
+            .to_string()
+            .contains("table is required for TABLE"));
+        assert!(
+            PermissionAssignment::new(blank_table, "SELECT", "analyst", None, 
None)
+                .unwrap_err()
+                .to_string()
+                .contains("table is required for TABLE")
+        );
+        let empty = 
serde_json::from_str::<PermissionColumns>(r#"{"columnNames":[]}"#).unwrap();
+        assert!(message(empty.validate().unwrap_err()).contains("columnNames 
cannot be empty"));
+        let rejected = PermissionAssignment::new(
+            PermissionResource::column("sales", "orders"),
+            "SELECT",
+            "analyst",
+            Some(empty),
+            None,
+        )
+        .unwrap_err()
+        .to_string();
+        assert!(
+            rejected.contains("columnNames cannot be empty"),
+            "{rejected:?}"
+        );
+        for json in [
+            r#"{"columnNames":[" "]}"#,
+            r#"{"excludedColumnNames":["id","id"]}"#,
+        ] {
+            let columns = 
serde_json::from_str::<PermissionColumns>(json).unwrap();
+            assert!(columns.validate().is_err(), "{json}");
+        }
+    }
+
+    #[test]
+    fn test_send_paths_canonicalize_values_no_constructor_could_have_built() {
+        let blank_view: PermissionResource = serde_json::from_str(
+            
r#"{"type":"TABLE","database":"sales","table":"orders","view":""}"#,
+        )
+        .unwrap();
+        let assignment =
+            PermissionAssignment::new(blank_view.clone(), "select", "analyst", 
None, None).unwrap();
+        assert_eq!(
+            serde_json::to_string(&assignment).unwrap(),
+            
r#"{"resource":{"type":"TABLE","database":"sales","table":"orders"},"access":"SELECT","principal":"analyst"}"#
+        );
+        assert_eq!(
+            ListPermissionsRequest::new(blank_view)
+                .query_params()
+                .unwrap(),
+            vec![
+                ("resourceType", "TABLE".to_string()),
+                ("database", "sales".to_string()),
+                ("table", "orders".to_string()),
+            ]
+        );
+    }
+
+    #[test]
+    fn test_list_permissions_request_query_params() {
+        let mut request = 
ListPermissionsRequest::new(PermissionResource::database("sales"));
+        request.access = Some("createview".to_string());
+        request.principal = Some("analyst".to_string());
+        request.max_results = Some(25);
+        request.page_token = Some("start".to_string());
+        assert_eq!(
+            request.query_params().unwrap(),
+            vec![
+                ("resourceType", "DATABASE".to_string()),
+                ("database", "sales".to_string()),
+                ("principal", "analyst".to_string()),
+                ("access", "CREATEVIEW".to_string()),
+                ("maxResults", "25".to_string()),
+                ("pageToken", "start".to_string()),
+            ]
+        );
+        let mut request =
+            ListPermissionsRequest::new(PermissionResource::function("sales", 
"calculate_tax"));
+        request.principal = Some("  ".to_string());
+        request.page_token = Some(" \t".to_string());
+        assert_eq!(
+            request.query_params().unwrap(),
+            vec![
+                ("resourceType", "FUNCTION".to_string()),
+                ("database", "sales".to_string()),
+                ("function", "calculate_tax".to_string()),
+                ("pageToken", " \t".to_string()),
+            ]
+        );
+        assert_eq!(
+            ListPermissionsRequest::new(PermissionResource::view("sales", 
"daily_orders"))
+                .query_params()
+                .unwrap(),
+            vec![
+                ("resourceType", "VIEW".to_string()),
+                ("database", "sales".to_string()),
+                ("view", "daily_orders".to_string()),
+            ]
+        );
+        let mut request = 
ListPermissionsRequest::new(PermissionResource::database("sales"));
+        request.access = Some("SELECT".to_string());
+        assert!(request
+            .query_params()
+            .unwrap_err()
+            .to_string()
+            .contains("not valid for DATABASE"));
+        let mut request = 
ListPermissionsRequest::new(PermissionResource::catalog());
+        request.principal = Some("p".repeat(129));
+        assert!(request
+            .query_params()
+            .unwrap_err()
+            .to_string()
+            .contains("128"));
+        for max_results in [0, 1001] {
+            let mut request = 
ListPermissionsRequest::new(PermissionResource::catalog());
+            request.max_results = Some(max_results);
+            assert!(
+                request
+                    .query_params()
+                    .unwrap_err()
+                    .to_string()
+                    .contains("1000"),
+                "{max_results}"
+            );
+        }
+    }
+
+    /// Java's `String.trim` strips only characters `<= U+0020`, so an em 
space or a
+    /// non-breaking space is a legal principal, not a blank one.
+    #[test]
+    fn test_is_blank_matches_java_trim_semantics() {
+        for blank in ["", " ", "\t", "\n", " \r\n\t "] {
+            assert!(is_blank(blank), "{blank:?}");
+        }
+        for present in ["\u{2003}", "\u{00a0}", "a", " a "] {
+            assert!(!is_blank(present), "{present:?}");
+        }
+        assert!(PermissionAssignment::validate_principal("\u{2003}").is_ok());
+        let mut request = 
ListPermissionsRequest::new(PermissionResource::catalog());
+        request.principal = Some("\u{2003}".to_string());
+        assert_eq!(
+            request.query_params().unwrap(),
+            vec![
+                ("resourceType", "CATALOG".to_string()),
+                ("principal", "\u{2003}".to_string()),
+            ]
+        );
+    }
+}
diff --git a/crates/paimon/src/api/mod.rs b/crates/paimon/src/api/mod.rs
index 181d0fa2..7f765cf7 100644
--- a/crates/paimon/src/api/mod.rs
+++ b/crates/paimon/src/api/mod.rs
@@ -21,6 +21,7 @@
 
 pub mod api_request;
 pub mod auth;
+pub mod management;
 pub mod resource_paths;
 pub mod rest_api;
 pub mod rest_client;
@@ -34,15 +35,21 @@ pub use api_request::{
     AlterDatabaseRequest, AlterTableRequest, AuthTableQueryRequest, 
CreateDatabaseRequest,
     CreateFunctionRequest, CreatePartitionsRequest, CreateTableRequest, 
CreateViewRequest,
     DropPartitionsRequest, ListPartitionsByFilterRequest, 
ListPartitionsByNamesRequest,
-    RenameTableRequest,
+    RenameTableRequest, RevokePermissionRequest,
 };
 
 // Re-export response types
 pub use api_response::{
     AuditRESTResponse, AuthTableQueryResponse, ConfigResponse, ErrorResponse, 
GetDatabaseResponse,
     GetFunctionResponse, GetTableResponse, GetTableTokenResponse, 
GetViewResponse,
-    ListDatabasesResponse, ListFunctionsResponse, ListPartitionsResponse, 
ListTablesResponse,
-    ListViewsResponse, PagedList,
+    ListDatabasesResponse, ListFunctionsResponse, ListPartitionsResponse, 
ListPermissionsResponse,
+    ListTablesResponse, ListViewsResponse, PagedList,
+};
+
+// Re-export management types
+pub use management::{
+    ListPermissionsRequest, PermissionAccess, PermissionAssignment, 
PermissionColumns,
+    PermissionResource, ResourceType,
 };
 
 // Re-export error types
diff --git a/crates/paimon/src/api/resource_paths.rs 
b/crates/paimon/src/api/resource_paths.rs
index 01351956..b1652172 100644
--- a/crates/paimon/src/api/resource_paths.rs
+++ b/crates/paimon/src/api/resource_paths.rs
@@ -35,6 +35,7 @@ impl ResourcePaths {
     const PARTITIONS: &'static str = "partitions";
     const VIEWS: &'static str = "views";
     const FUNCTIONS: &'static str = "functions";
+    const PERMISSIONS: &'static str = "permissions";
 
     /// Create a new ResourcePaths with the given prefix.
     pub fn new(prefix: &str) -> Self {
@@ -243,6 +244,21 @@ impl ResourcePaths {
             self.partitions(database_name, table_name)
         )
     }
+
+    /// Get the permission collection of the catalog (`{base}/permissions`).
+    pub fn permissions(&self) -> String {
+        format!("{}/{}", self.base_path, Self::PERMISSIONS)
+    }
+
+    /// Get the action endpoint that grants or replaces one permission 
assignment.
+    pub fn grant_permission(&self) -> String {
+        format!("{}/grant", self.permissions())
+    }
+
+    /// Get the action endpoint that revokes one permission assignment.
+    pub fn revoke_permission(&self) -> String {
+        format!("{}/revoke", self.permissions())
+    }
 }
 
 #[cfg(test)]
@@ -344,4 +360,12 @@ mod tests {
             
"/v1/catalog/databases/analytics+db/tables/user+events/partitions/drop"
         );
     }
+
+    #[test]
+    fn test_permission_paths_hang_off_the_catalog_prefix() {
+        let paths = ResourcePaths::new("catalog");
+        assert_eq!(paths.permissions(), "/v1/catalog/permissions");
+        assert_eq!(paths.grant_permission(), "/v1/catalog/permissions/grant");
+        assert_eq!(paths.revoke_permission(), 
"/v1/catalog/permissions/revoke");
+    }
 }
diff --git a/crates/paimon/src/api/rest_api.rs 
b/crates/paimon/src/api/rest_api.rs
index 8cd943c9..da1f72b7 100644
--- a/crates/paimon/src/api/rest_api.rs
+++ b/crates/paimon/src/api/rest_api.rs
@@ -32,14 +32,16 @@ use super::api_request::{
     AlterDatabaseRequest, AlterTableRequest, AuthTableQueryRequest, 
CreateDatabaseRequest,
     CreateFunctionRequest, CreatePartitionsRequest, CreateTableRequest, 
CreateViewRequest,
     DropPartitionsRequest, ListPartitionsByFilterRequest, 
ListPartitionsByNamesRequest,
-    RenameTableRequest,
+    RenameTableRequest, RevokePermissionRequest,
 };
 use super::api_response::{
     AuthTableQueryResponse, ConfigResponse, GetDatabaseResponse, 
GetFunctionResponse,
     GetTableResponse, GetViewResponse, ListDatabasesResponse, 
ListFunctionsResponse,
-    ListPartitionsResponse, ListTablesResponse, ListViewsResponse, PagedList,
+    ListPartitionsResponse, ListPermissionsResponse, ListTablesResponse, 
ListViewsResponse,
+    PagedList,
 };
 use super::auth::{AuthProviderFactory, RESTAuthFunction};
+use super::management::{ListPermissionsRequest, PermissionAssignment, 
PermissionResource};
 use super::resource_paths::ResourcePaths;
 use super::rest_util::RESTUtil;
 
@@ -846,6 +848,48 @@ impl RESTApi {
         self.client.post(&path, &request).await
     }
 
+    // ==================== Permission Management ====================
+    //
+    // Experimental REST management API (Java `RESTPermissionManagement`).
+
+    /// List the direct assignments on one exact resource or scope: the server 
synthesizes none
+    /// of those inherited through `CATALOG_ALL` / `DATABASE_ALL`, and may 
still list expired ones.
+    pub async fn list_permissions_paged(
+        &self,
+        request: &ListPermissionsRequest,
+    ) -> Result<PagedList<PermissionAssignment>> {
+        let params = request.query_params()?;
+        let response: ListPermissionsResponse = self
+            .client
+            .get(&self.resource_paths.permissions(), Some(&params))
+            .await?;
+        Ok(PagedList::new(
+            response.permissions,
+            response.next_page_token,
+        ))
+    }
+
+    /// Grant an assignment, replacing the expiry and column range of an 
identical one.
+    pub async fn grant_permission(&self, assignment: &PermissionAssignment) -> 
Result<()> {
+        let assignment = assignment.clone().canonicalized()?;
+        let path = self.resource_paths.grant_permission();
+        let _resp: serde_json::Value = self.client.post(&path, 
&assignment).await?;
+        Ok(())
+    }
+
+    /// Revoke an assignment by identity; revoking an absent one succeeds.
+    pub async fn revoke_permission(
+        &self,
+        resource: &PermissionResource,
+        access: &str,
+        principal: &str,
+    ) -> Result<()> {
+        let request = RevokePermissionRequest::new(resource.clone(), access, 
principal)?;
+        let path = self.resource_paths.revoke_permission();
+        let _resp: serde_json::Value = self.client.post(&path, 
&request).await?;
+        Ok(())
+    }
+
     // ==================== Commit Operations ====================
 
     /// Commit a snapshot for a table.
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs 
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index 09f9691e..41ec6759 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -25,6 +25,7 @@ use std::sync::Arc;
 
 use async_trait::async_trait;
 
+use crate::api::management::{ListPermissionsRequest, PermissionAssignment, 
PermissionResource};
 use crate::api::rest_api::RESTApi;
 use crate::api::rest_error::RestError;
 use crate::api::PagedList;
@@ -126,6 +127,33 @@ impl RESTCatalog {
             .list_databases_paged(max_results, page_token, 
database_name_pattern)
             .await
     }
+
+    // ======================= permission management ==========================
+    //
+    // Like Java's `RESTCatalog.permissionManagement()`, these live on the 
REST catalog only
+    // and are deliberately not part of the `Catalog` trait.
+
+    pub async fn list_permissions_paged(
+        &self,
+        request: &ListPermissionsRequest,
+    ) -> Result<PagedList<PermissionAssignment>> {
+        self.api.list_permissions_paged(request).await
+    }
+
+    pub async fn grant_permission(&self, assignment: &PermissionAssignment) -> 
Result<()> {
+        self.api.grant_permission(assignment).await
+    }
+
+    pub async fn revoke_permission(
+        &self,
+        resource: &PermissionResource,
+        access: &str,
+        principal: &str,
+    ) -> Result<()> {
+        self.api
+            .revoke_permission(resource, access, principal)
+            .await
+    }
 }
 
 // ============================================================================
diff --git a/crates/paimon/tests/mock_server.rs 
b/crates/paimon/tests/mock_server.rs
index b5b3bf56..81572025 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -38,8 +38,9 @@ use paimon::api::{
     CreateFunctionRequest, CreatePartitionsRequest, CreateViewRequest, 
DropPartitionsRequest,
     ErrorResponse, GetDatabaseResponse, GetFunctionResponse, GetTableResponse, 
GetViewResponse,
     ListDatabasesResponse, ListFunctionsResponse, 
ListPartitionsByFilterRequest,
-    ListPartitionsByNamesRequest, ListPartitionsResponse, ListTablesResponse, 
ListViewsResponse,
-    RenameTableRequest, ResourcePaths,
+    ListPartitionsByNamesRequest, ListPartitionsResponse, 
ListPermissionsResponse,
+    ListTablesResponse, ListViewsResponse, PermissionAssignment, 
PermissionResource,
+    RenameTableRequest, ResourcePaths, ResourceType, RevokePermissionRequest,
 };
 use paimon::catalog::{Function, Identifier};
 use paimon::spec::Partition;
@@ -70,6 +71,11 @@ struct MockState {
     drop_partitions_calls: Vec<(String, String, DropPartitionsRequest)>,
     create_partitions_error_status: Option<StatusCode>,
     list_partitions_error_status: Option<StatusCode>,
+    permissions: Vec<PermissionAssignment>,
+    list_permissions_queries: Vec<HashMap<String, String>>,
+    grant_permission_bodies: Vec<serde_json::Value>,
+    revoke_permission_bodies: Vec<serde_json::Value>,
+    grant_permission_error_status: Option<StatusCode>,
     /// ECS metadata role name (for token loader testing)
     ecs_role_name: Option<String>,
     /// ECS metadata token (for token loader testing)
@@ -111,22 +117,22 @@ fn partition_from_spec(spec: HashMap<String, String>) -> 
Partition {
     }
 }
 
-fn paginate_names(
-    names: Vec<String>,
+fn paginate<T: Clone>(
+    items: Vec<T>,
     params: &HashMap<String, String>,
     page_size: Option<usize>,
-) -> (Vec<String>, Option<String>) {
+) -> (Vec<T>, Option<String>) {
     let Some(page_size) = page_size else {
-        return (names, None);
+        return (items, None);
     };
     let offset = params
         .get("pageToken")
         .and_then(|token| token.parse::<usize>().ok())
         .unwrap_or(0)
-        .min(names.len());
-    let end = (offset + page_size).min(names.len());
-    let next_page_token = (end < names.len()).then(|| end.to_string());
-    (names[offset..end].to_vec(), next_page_token)
+        .min(items.len());
+    let end = (offset + page_size).min(items.len());
+    let next_page_token = (end < items.len()).then(|| end.to_string());
+    (items[offset..end].to_vec(), next_page_token)
 }
 
 #[derive(Clone)]
@@ -488,7 +494,7 @@ impl RESTServer {
             .filter_map(|key| 
key.strip_prefix(&prefix).map(ToString::to_string))
             .collect();
         views.sort();
-        let (views, next_page_token) = paginate_names(views, &params, 
s.list_page_size);
+        let (views, next_page_token) = paginate(views, &params, 
s.list_page_size);
         (
             StatusCode::OK,
             Json(ListViewsResponse::new(views, next_page_token)),
@@ -594,7 +600,7 @@ impl RESTServer {
             .filter_map(|key| 
key.strip_prefix(&prefix).map(ToString::to_string))
             .collect();
         functions.sort();
-        let (functions, next_page_token) = paginate_names(functions, &params, 
s.list_page_size);
+        let (functions, next_page_token) = paginate(functions, &params, 
s.list_page_size);
         (
             StatusCode::OK,
             Json(ListFunctionsResponse::new(functions, next_page_token)),
@@ -1156,6 +1162,116 @@ impl RESTServer {
             .into_response()
     }
 
+    // ==================== Permission management ====================
+
+    fn same_assignment_identity(left: &PermissionAssignment, right: 
&PermissionAssignment) -> bool {
+        left.resource() == right.resource()
+            && left.access() == right.access()
+            && left.principal() == right.principal()
+    }
+
+    fn bad_request(message: String) -> axum::response::Response {
+        let error = ErrorResponse::new(None, None, Some(message), Some(400));
+        (StatusCode::BAD_REQUEST, Json(error)).into_response()
+    }
+
+    /// Handle GET {prefix}/permissions - direct assignments on the exact 
resource in the query.
+    pub async fn list_permissions(
+        Query(params): Query<HashMap<String, String>>,
+        Extension(state): Extension<Arc<RESTServer>>,
+    ) -> impl IntoResponse {
+        let mut inner = state.inner.lock().unwrap();
+        inner.list_permissions_queries.push(params.clone());
+        let Some(resource_type) = params
+            .get("resourceType")
+            .and_then(|value| value.parse::<ResourceType>().ok())
+        else {
+            return Self::bad_request("resourceType is required".to_string());
+        };
+        let locator = |name: &str| params.get(name).map(String::as_str);
+        let resource = match PermissionResource::new(
+            resource_type,
+            locator("database"),
+            locator("table"),
+            locator("function"),
+            locator("view"),
+        ) {
+            Ok(resource) => resource,
+            Err(error) => return Self::bad_request(error.to_string()),
+        };
+        let matching: Vec<PermissionAssignment> = inner
+            .permissions
+            .iter()
+            .filter(|assignment| assignment.resource() == &resource)
+            .filter(|assignment| {
+                params
+                    .get("principal")
+                    .is_none_or(|p| p == assignment.principal())
+            })
+            .filter(|assignment| {
+                params
+                    .get("access")
+                    .is_none_or(|a| a == assignment.access())
+            })
+            .cloned()
+            .collect();
+        let page_size = params
+            .get("maxResults")
+            .and_then(|value| value.parse().ok());
+        let (permissions, next_page_token) = paginate(matching, &params, 
page_size);
+        (
+            StatusCode::OK,
+            Json(ListPermissionsResponse::new(permissions, next_page_token)),
+        )
+            .into_response()
+    }
+
+    /// Handle POST {prefix}/permissions/grant - upsert by (resource, access, 
principal).
+    pub async fn grant_permission(
+        Extension(state): Extension<Arc<RESTServer>>,
+        Json(body): Json<serde_json::Value>,
+    ) -> impl IntoResponse {
+        let mut inner = state.inner.lock().unwrap();
+        inner.grant_permission_bodies.push(body.clone());
+        if let Some(status) = inner.grant_permission_error_status {
+            let error = ErrorResponse::new(
+                None,
+                None,
+                Some("forbidden".to_string()),
+                Some(status.as_u16() as i32),
+            );
+            return (status, Json(error)).into_response();
+        }
+        let assignment: PermissionAssignment = match 
serde_json::from_value(body) {
+            Ok(assignment) => assignment,
+            Err(error) => return Self::bad_request(error.to_string()),
+        };
+        inner
+            .permissions
+            .retain(|existing| !Self::same_assignment_identity(existing, 
&assignment));
+        inner.permissions.push(assignment);
+        StatusCode::OK.into_response()
+    }
+
+    /// Handle POST {prefix}/permissions/revoke - idempotent removal by 
identity.
+    pub async fn revoke_permission(
+        Extension(state): Extension<Arc<RESTServer>>,
+        Json(body): Json<serde_json::Value>,
+    ) -> impl IntoResponse {
+        let mut inner = state.inner.lock().unwrap();
+        inner.revoke_permission_bodies.push(body.clone());
+        let request: RevokePermissionRequest = match 
serde_json::from_value(body) {
+            Ok(request) => request,
+            Err(error) => return Self::bad_request(error.to_string()),
+        };
+        inner.permissions.retain(|existing| {
+            !(existing.resource() == &request.resource
+                && existing.access() == request.access
+                && existing.principal() == request.principal)
+        });
+        StatusCode::OK.into_response()
+    }
+
     /// Handle POST /rename-table - rename a table.
     pub async fn rename_table(
         Extension(state): Extension<Arc<RESTServer>>,
@@ -1548,6 +1664,30 @@ impl RESTServer {
             .unwrap_or_default()
     }
 
+    /// Every query string received by `GET /permissions`.
+    pub fn list_permissions_queries(&self) -> Vec<HashMap<String, String>> {
+        self.inner.lock().unwrap().list_permissions_queries.clone()
+    }
+
+    /// Raw JSON bodies received by `POST /permissions/grant`.
+    pub fn grant_permission_bodies(&self) -> Vec<serde_json::Value> {
+        self.inner.lock().unwrap().grant_permission_bodies.clone()
+    }
+
+    /// Raw JSON bodies received by `POST /permissions/revoke`.
+    pub fn revoke_permission_bodies(&self) -> Vec<serde_json::Value> {
+        self.inner.lock().unwrap().revoke_permission_bodies.clone()
+    }
+
+    pub fn permissions(&self) -> Vec<PermissionAssignment> {
+        self.inner.lock().unwrap().permissions.clone()
+    }
+
+    /// Make every grant fail with `status` (e.g. 403) instead of storing it.
+    pub fn set_grant_permission_error_status(&self, status: 
Option<StatusCode>) {
+        self.inner.lock().unwrap().grant_permission_error_status = status;
+    }
+
     /// Return all create-partitions calls received by the server.
     pub fn create_partitions_calls(&self) -> Vec<(String, String, 
CreatePartitionsRequest)> {
         self.inner.lock().unwrap().create_partitions_calls.clone()
@@ -1711,6 +1851,18 @@ pub async fn start_mock_server(
             &format!("{prefix}/tables/rename"),
             post(RESTServer::rename_table),
         )
+        .route(
+            &format!("{prefix}/permissions"),
+            get(RESTServer::list_permissions),
+        )
+        .route(
+            &format!("{prefix}/permissions/grant"),
+            post(RESTServer::grant_permission),
+        )
+        .route(
+            &format!("{prefix}/permissions/revoke"),
+            post(RESTServer::revoke_permission),
+        )
         // ECS metadata endpoints (for token loader testing)
         .route(
             "/ram/security-credentials/",
diff --git a/crates/paimon/tests/rest_api_test.rs 
b/crates/paimon/tests/rest_api_test.rs
index 086c3301..f8ef468b 100644
--- a/crates/paimon/tests/rest_api_test.rs
+++ b/crates/paimon/tests/rest_api_test.rs
@@ -22,9 +22,13 @@
 
 use std::collections::HashMap;
 
+use axum::http::StatusCode;
 use paimon::api::auth::{DLFECSTokenLoader, DLFToken, DLFTokenLoader};
 use paimon::api::rest_api::RESTApi;
-use paimon::api::{ConfigResponse, CreatePartitionsRequest, 
DropPartitionsRequest};
+use paimon::api::{
+    ConfigResponse, CreatePartitionsRequest, DropPartitionsRequest, 
ListPermissionsRequest,
+    PermissionAssignment, PermissionColumns, PermissionResource, RestError,
+};
 use paimon::catalog::{Function, FunctionDefinition, Identifier, ViewSchema};
 use paimon::common::Options;
 use paimon::spec::DataField;
@@ -868,6 +872,265 @@ async fn 
test_list_partitions_rejects_repeated_page_token() {
     );
 }
 
+// ==================== Permission Management Tests ====================
+
+fn orders_table() -> PermissionResource {
+    PermissionResource::table("sales", "orders")
+}
+
+fn assignment(access: &str, principal: &str) -> PermissionAssignment {
+    PermissionAssignment::new(orders_table(), access, principal, None, 
None).unwrap()
+}
+
+#[tokio::test]
+async fn test_list_permissions_sends_every_filter_and_parses_the_page() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    ctx.api
+        .grant_permission(&assignment("SELECT", "analyst"))
+        .await
+        .unwrap();
+    ctx.api
+        .grant_permission(&assignment("UPDATE", "writer"))
+        .await
+        .unwrap();
+
+    let mut request = ListPermissionsRequest::new(orders_table());
+    request.principal = Some("analyst".to_string());
+    request.access = Some("select".to_string());
+    request.max_results = Some(25);
+    request.page_token = Some("0".to_string());
+    let page = ctx.api.list_permissions_paged(&request).await.unwrap();
+
+    assert_eq!(page.elements, vec![assignment("SELECT", "analyst")]);
+    assert_eq!(page.next_page_token, None);
+    assert_eq!(
+        ctx.server.list_permissions_queries(),
+        vec![HashMap::from([
+            ("resourceType".to_string(), "TABLE".to_string()),
+            ("database".to_string(), "sales".to_string()),
+            ("table".to_string(), "orders".to_string()),
+            ("principal".to_string(), "analyst".to_string()),
+            ("access".to_string(), "SELECT".to_string()),
+            ("maxResults".to_string(), "25".to_string()),
+            ("pageToken".to_string(), "0".to_string()),
+        ])]
+    );
+}
+
+#[tokio::test]
+async fn test_list_permissions_pages_with_the_server_token() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    for principal in ["a", "b", "c"] {
+        ctx.api
+            .grant_permission(&assignment("SELECT", principal))
+            .await
+            .unwrap();
+    }
+    let mut request = ListPermissionsRequest::new(orders_table());
+    request.max_results = Some(2);
+    let first = ctx.api.list_permissions_paged(&request).await.unwrap();
+    assert_eq!(first.elements.len(), 2);
+    let token = first.next_page_token.expect("a second page");
+    request.page_token = Some(token);
+    let second = ctx.api.list_permissions_paged(&request).await.unwrap();
+    assert_eq!(second.elements, vec![assignment("SELECT", "c")]);
+    assert_eq!(second.next_page_token, None);
+}
+
+#[tokio::test]
+async fn test_grant_and_revoke_post_the_java_wire_shapes() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    let granted = PermissionAssignment::new(
+        orders_table(),
+        "select",
+        "analyst",
+        None,
+        Some("2027-01-01T00:00:00Z"),
+    )
+    .unwrap();
+    ctx.api.grant_permission(&granted).await.unwrap();
+    // Granting the same identity again replaces the expiry instead of adding 
a row.
+    ctx.api
+        .grant_permission(&assignment("SELECT", "analyst"))
+        .await
+        .unwrap();
+    assert_eq!(
+        ctx.server.permissions(),
+        vec![assignment("SELECT", "analyst")]
+    );
+
+    ctx.api
+        .revoke_permission(&orders_table(), "select", "analyst")
+        .await
+        .unwrap();
+    assert!(ctx.server.permissions().is_empty());
+
+    assert_eq!(
+        ctx.server.grant_permission_bodies()[0],
+        json!({
+            "resource": {"type": "TABLE", "database": "sales", "table": 
"orders"},
+            "access": "SELECT",
+            "principal": "analyst",
+            "expireTime": "2027-01-01T00:00:00Z"
+        })
+    );
+    assert_eq!(
+        ctx.server.revoke_permission_bodies(),
+        vec![json!({
+            "resource": {"type": "TABLE", "database": "sales", "table": 
"orders"},
+            "access": "SELECT",
+            "principal": "analyst"
+        })]
+    );
+}
+
+#[tokio::test]
+async fn test_column_grant_carries_the_range_but_revoke_only_the_identity() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    let columns = PermissionColumns::names(vec!["id".to_string(), 
"region".to_string()]).unwrap();
+    let granted = PermissionAssignment::new(
+        PermissionResource::column("sales", "orders"),
+        "SELECT",
+        "analyst",
+        Some(columns),
+        None,
+    )
+    .unwrap();
+    ctx.api.grant_permission(&granted).await.unwrap();
+    ctx.api
+        .revoke_permission(granted.resource(), granted.access(), 
granted.principal())
+        .await
+        .unwrap();
+
+    let grant = &ctx.server.grant_permission_bodies()[0];
+    assert_eq!(grant["resource"]["type"], "COLUMN");
+    assert_eq!(grant["columns"], json!({"columnNames": ["id", "region"]}));
+    let revoke = &ctx.server.revoke_permission_bodies()[0];
+    assert_eq!(revoke["resource"]["type"], "COLUMN");
+    assert!(revoke.get("columns").is_none());
+    assert!(revoke.get("expireTime").is_none());
+}
+
+#[tokio::test]
+async fn test_forbidden_grant_surfaces_the_rest_error() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    ctx.server
+        .set_grant_permission_error_status(Some(StatusCode::FORBIDDEN));
+    let error = ctx
+        .api
+        .grant_permission(&assignment("SELECT", "denied"))
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(
+            error,
+            paimon::Error::RestApi {
+                source: RestError::Forbidden { .. }
+            }
+        ),
+        "{error:?}"
+    );
+}
+
+#[tokio::test]
+async fn test_revoking_an_absent_assignment_is_idempotent() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    for _ in 0..2 {
+        ctx.api
+            .revoke_permission(&orders_table(), "SELECT", "missing")
+            .await
+            .unwrap();
+    }
+    assert_eq!(ctx.server.revoke_permission_bodies().len(), 2);
+}
+
+#[tokio::test]
+async fn test_invalid_permission_requests_never_reach_the_server() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    let error = ctx
+        .api
+        .revoke_permission(&PermissionResource::catalog(), "SELECT", "analyst")
+        .await
+        .unwrap_err();
+    assert!(
+        error.to_string().contains("not valid for CATALOG"),
+        "{error}"
+    );
+    let mut request = ListPermissionsRequest::new(orders_table());
+    request.max_results = Some(1001);
+    let error = ctx.api.list_permissions_paged(&request).await.unwrap_err();
+    assert!(error.to_string().contains("1000"), "{error}");
+    assert!(ctx.server.revoke_permission_bodies().is_empty());
+    assert!(ctx.server.list_permissions_queries().is_empty());
+}
+
+#[tokio::test]
+async fn test_grant_sends_the_canonical_form_of_a_listed_assignment() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    let listed: PermissionAssignment = serde_json::from_value(json!({
+        "resource": {"type": "TABLE", "database": "sales", "table": "orders", 
"view": ""},
+        "access": "select",
+        "principal": "analyst",
+    }))
+    .unwrap();
+    ctx.api.grant_permission(&listed).await.unwrap();
+    assert_eq!(
+        ctx.server.grant_permission_bodies(),
+        vec![json!({
+            "resource": {"type": "TABLE", "database": "sales", "table": 
"orders"},
+            "access": "SELECT",
+            "principal": "analyst"
+        })]
+    );
+
+    let mut request = ListPermissionsRequest::new(listed.resource().clone());
+    request.access = Some("select".to_string());
+    ctx.api.list_permissions_paged(&request).await.unwrap();
+    assert_eq!(
+        ctx.server.list_permissions_queries(),
+        vec![HashMap::from([
+            ("resourceType".to_string(), "TABLE".to_string()),
+            ("database".to_string(), "sales".to_string()),
+            ("table".to_string(), "orders".to_string()),
+            ("access".to_string(), "SELECT".to_string()),
+        ])]
+    );
+}
+
+#[tokio::test]
+async fn 
test_a_resource_no_constructor_could_have_built_never_reaches_the_server() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    let blank = PermissionResource::table("sales", "");
+    let assignment: PermissionAssignment = serde_json::from_value(json!({
+        "resource": {"type": "TABLE", "database": "sales", "table": ""},
+        "access": "SELECT",
+        "principal": "analyst",
+    }))
+    .unwrap();
+    let rejected = |error: paimon::Error| {
+        assert!(
+            error.to_string().contains("table is required for TABLE"),
+            "{error}"
+        );
+    };
+    rejected(ctx.api.grant_permission(&assignment).await.unwrap_err());
+    rejected(
+        ctx.api
+            .revoke_permission(&blank, "SELECT", "analyst")
+            .await
+            .unwrap_err(),
+    );
+    rejected(
+        ctx.api
+            .list_permissions_paged(&ListPermissionsRequest::new(blank))
+            .await
+            .unwrap_err(),
+    );
+    assert!(ctx.server.grant_permission_bodies().is_empty());
+    assert!(ctx.server.revoke_permission_bodies().is_empty());
+    assert!(ctx.server.list_permissions_queries().is_empty());
+}
+
 // ==================== Rename Table Tests ====================
 
 #[tokio::test]
diff --git a/crates/paimon/tests/rest_catalog_test.rs 
b/crates/paimon/tests/rest_catalog_test.rs
index 63729cc5..691c1159 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -28,6 +28,7 @@ use arrow_schema::{DataType as ArrowDataType, Field as 
ArrowField, Schema as Arr
 use axum::http::StatusCode;
 use futures::TryStreamExt;
 use paimon::api::ConfigResponse;
+use paimon::api::{ListPermissionsRequest, PermissionAssignment, 
PermissionResource};
 use paimon::catalog::{Catalog, Function, FunctionDefinition, Identifier, 
RESTCatalog, ViewSchema};
 use paimon::common::Options;
 use paimon::spec::{
@@ -2460,3 +2461,30 @@ async fn test_load_table_rejects_unknown_declared_type() 
{
         "{err:?}"
     );
 }
+
+#[tokio::test]
+async fn test_rest_catalog_manages_permissions_end_to_end() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let resource = PermissionResource::table("default", "orders");
+    let assignment =
+        PermissionAssignment::new(resource.clone(), "select", "analyst", None, 
None).unwrap();
+
+    ctx.catalog.grant_permission(&assignment).await.unwrap();
+    let page = ctx
+        .catalog
+        .list_permissions_paged(&ListPermissionsRequest::new(resource.clone()))
+        .await
+        .unwrap();
+    assert_eq!(page.elements, vec![assignment]);
+
+    ctx.catalog
+        .revoke_permission(&resource, "SELECT", "analyst")
+        .await
+        .unwrap();
+    let page = ctx
+        .catalog
+        .list_permissions_paged(&ListPermissionsRequest::new(resource))
+        .await
+        .unwrap();
+    assert!(page.elements.is_empty());
+}

Reply via email to