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

lzljs3620320 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 6c1a0c55aa [python] Add ForbiddenException handling in REST catalog 
(#6970)
6c1a0c55aa is described below

commit 6c1a0c55aa1ab62ef9445ffee7a254cb70111d28
Author: Jiajia Li <[email protected]>
AuthorDate: Thu Jan 8 09:20:41 2026 +0800

    [python] Add ForbiddenException handling in REST catalog (#6970)
---
 paimon-python/pypaimon/api/auth.py                 |   2 +-
 paimon-python/pypaimon/api/rest_api.py             |   3 +-
 .../pypaimon/catalog/rest/rest_catalog.py          |  79 +++++---
 .../pypaimon/tests/rest/rest_permission_test.py    | 199 +++++++++++++++++++++
 4 files changed, 261 insertions(+), 22 deletions(-)

diff --git a/paimon-python/pypaimon/api/auth.py 
b/paimon-python/pypaimon/api/auth.py
index f69e43158c..9e41edb9ea 100644
--- a/paimon-python/pypaimon/api/auth.py
+++ b/paimon-python/pypaimon/api/auth.py
@@ -36,7 +36,7 @@ class AuthProvider(ABC):
 
     @abstractmethod
     def merge_auth_header(
-            self, base_header: Dict[str, str], parammeter: RESTAuthParameter
+            self, base_header: Dict[str, str], parameter: RESTAuthParameter
     ) -> Dict[str, str]:
         """Merge authorization header into header."""
 
diff --git a/paimon-python/pypaimon/api/rest_api.py 
b/paimon-python/pypaimon/api/rest_api.py
index 806831d954..6b871ca58e 100755
--- a/paimon-python/pypaimon/api/rest_api.py
+++ b/paimon-python/pypaimon/api/rest_api.py
@@ -88,6 +88,7 @@ class RESTApi:
         self.resource_paths = ResourcePaths.for_catalog_properties(options)
 
     def __build_paged_query_params(
+            self,
             max_results: Optional[int],
             page_token: Optional[str],
             name_patterns: Dict[str, str],
@@ -99,7 +100,7 @@ class RESTApi:
         if page_token is not None and page_token.strip():
             query_params[RESTApi.PAGE_TOKEN] = page_token
 
-        for key, value in name_patterns:
+        for key, value in name_patterns.items():
             if key and value and key.strip() and value.strip():
                 query_params[key] = value
 
diff --git a/paimon-python/pypaimon/catalog/rest/rest_catalog.py 
b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
index 5a0ca01ce4..5d9462f6c3 100644
--- a/paimon-python/pypaimon/catalog/rest/rest_catalog.py
+++ b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
@@ -19,12 +19,15 @@ from typing import Any, Callable, Dict, List, Optional, 
Union
 
 from pypaimon.api.api_response import GetTableResponse, PagedList
 from pypaimon.api.rest_api import RESTApi
-from pypaimon.api.rest_exception import NoSuchResourceException, 
AlreadyExistsException
+from pypaimon.api.rest_exception import NoSuchResourceException, 
AlreadyExistsException, ForbiddenException
 from pypaimon.catalog.catalog import Catalog
 from pypaimon.catalog.catalog_context import CatalogContext
 from pypaimon.catalog.catalog_environment import CatalogEnvironment
-from pypaimon.catalog.catalog_exception import TableNotExistException, 
DatabaseAlreadyExistException, \
-    TableAlreadyExistException, DatabaseNotExistException
+from pypaimon.catalog.catalog_exception import (
+    TableNotExistException, DatabaseAlreadyExistException,
+    TableAlreadyExistException, DatabaseNotExistException,
+    TableNoPermissionException, DatabaseNoPermissionException
+)
 from pypaimon.catalog.database import Database
 from pypaimon.catalog.rest.property_change import PropertyChange
 from pypaimon.catalog.rest.rest_token_file_io import RESTTokenFileIO
@@ -89,11 +92,14 @@ class RESTCatalog(Catalog):
 
         Raises:
             TableNotExistException: If the target table does not exist
+            TableNoPermissionException: If no permission to access this table
         """
         try:
             return self.rest_api.commit_snapshot(identifier, table_uuid, 
snapshot, statistics)
         except NoSuchResourceException as e:
             raise TableNotExistException(identifier) from e
+        except ForbiddenException as e:
+            raise TableNoPermissionException(identifier) from e
         except Exception as e:
             # Handle other exceptions that might be thrown by the API
             raise RuntimeError(f"Failed to commit snapshot for table 
{identifier.get_full_name()}: {e}") from e
@@ -112,14 +118,21 @@ class RESTCatalog(Catalog):
             if not ignore_if_exists:
                 # Convert REST API exception to catalog exception
                 raise DatabaseAlreadyExistException(name) from e
+        except ForbiddenException as e:
+            raise DatabaseNoPermissionException(name) from e
 
     def get_database(self, name: str) -> Database:
-        response = self.rest_api.get_database(name)
-        options = response.options
-        options[Catalog.DB_LOCATION_PROP] = response.location
-        response.put_audit_options_to(options)
-        if response is not None:
-            return Database(name, options)
+        try:
+            response = self.rest_api.get_database(name)
+            options = response.options
+            options[Catalog.DB_LOCATION_PROP] = response.location
+            response.put_audit_options_to(options)
+            if response is not None:
+                return Database(name, options)
+        except NoSuchResourceException as e:
+            raise DatabaseNotExistException(name) from e
+        except ForbiddenException as e:
+            raise DatabaseNoPermissionException(name) from e
 
     def drop_database(self, name: str, ignore_if_not_exists: bool = False):
         try:
@@ -128,13 +141,25 @@ class RESTCatalog(Catalog):
             if not ignore_if_not_exists:
                 # Convert REST API exception to catalog exception
                 raise DatabaseNotExistException(name) from e
+        except ForbiddenException as e:
+            raise DatabaseNoPermissionException(name) from e
 
     def alter_database(self, name: str, changes: List[PropertyChange]):
-        set_properties, remove_keys = 
PropertyChange.get_set_properties_to_remove_keys(changes)
-        self.rest_api.alter_database(name, list(remove_keys), set_properties)
+        try:
+            set_properties, remove_keys = 
PropertyChange.get_set_properties_to_remove_keys(changes)
+            self.rest_api.alter_database(name, list(remove_keys), 
set_properties)
+        except NoSuchResourceException as e:
+            raise DatabaseNotExistException(name) from e
+        except ForbiddenException as e:
+            raise DatabaseNoPermissionException(name) from e
 
     def list_tables(self, database_name: str) -> List[str]:
-        return self.rest_api.list_tables(database_name)
+        try:
+            return self.rest_api.list_tables(database_name)
+        except NoSuchResourceException as e:
+            raise DatabaseNotExistException(database_name) from e
+        except ForbiddenException as e:
+            raise DatabaseNoPermissionException(database_name) from e
 
     def list_tables_paged(
             self,
@@ -143,12 +168,17 @@ class RESTCatalog(Catalog):
             page_token: Optional[str] = None,
             table_name_pattern: Optional[str] = None
     ) -> PagedList[str]:
-        return self.rest_api.list_tables_paged(
-            database_name,
-            max_results,
-            page_token,
-            table_name_pattern
-        )
+        try:
+            return self.rest_api.list_tables_paged(
+                database_name,
+                max_results,
+                page_token,
+                table_name_pattern
+            )
+        except NoSuchResourceException as e:
+            raise DatabaseNotExistException(database_name) from e
+        except ForbiddenException as e:
+            raise DatabaseNoPermissionException(database_name) from e
 
     def get_table(self, identifier: Union[str, Identifier]) -> FileStoreTable:
         if not isinstance(identifier, Identifier):
@@ -177,6 +207,8 @@ class RESTCatalog(Catalog):
         except NoSuchResourceException as e:
             if not ignore_if_not_exists:
                 raise TableNotExistException(identifier) from e
+        except ForbiddenException as e:
+            raise TableNoPermissionException(identifier) from e
 
     def alter_table(
         self,
@@ -191,10 +223,17 @@ class RESTCatalog(Catalog):
         except NoSuchResourceException as e:
             if not ignore_if_not_exists:
                 raise TableNotExistException(identifier) from e
+        except ForbiddenException as e:
+            raise TableNoPermissionException(identifier) from e
 
     def load_table_metadata(self, identifier: Identifier) -> TableMetadata:
-        response = self.rest_api.get_table(identifier)
-        return self.to_table_metadata(identifier.get_database_name(), response)
+        try:
+            response = self.rest_api.get_table(identifier)
+            return self.to_table_metadata(identifier.get_database_name(), 
response)
+        except NoSuchResourceException as e:
+            raise TableNotExistException(identifier) from e
+        except ForbiddenException as e:
+            raise TableNoPermissionException(identifier) from e
 
     def to_table_metadata(self, db: str, response: GetTableResponse) -> 
TableMetadata:
         schema = TableSchema.from_schema(response.schema_id, 
response.get_schema())
diff --git a/paimon-python/pypaimon/tests/rest/rest_permission_test.py 
b/paimon-python/pypaimon/tests/rest/rest_permission_test.py
new file mode 100644
index 0000000000..2c4844db7b
--- /dev/null
+++ b/paimon-python/pypaimon/tests/rest/rest_permission_test.py
@@ -0,0 +1,199 @@
+"""
+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.
+"""
+
+import time
+
+import pyarrow as pa
+
+from pypaimon import Schema
+from pypaimon.catalog.catalog_exception import (
+    DatabaseNoPermissionException,
+    TableNoPermissionException
+)
+from pypaimon.common.identifier import Identifier
+from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.schema.schema_change import SchemaChange
+from pypaimon.snapshot.snapshot import Snapshot
+from pypaimon.snapshot.snapshot_commit import PartitionStatistics
+from pypaimon.tests.rest.rest_base_test import RESTBaseTest
+
+
+class RESTPermissionTest(RESTBaseTest):
+    def setUp(self):
+        super().setUp()
+        self.pa_schema = pa.schema([
+            ('user_id', pa.int64()),
+            ('item_id', pa.int64()),
+            ('behavior', pa.string()),
+            ('dt', pa.string()),
+        ])
+
+    def test_get_database_no_permission(self):
+        self.rest_catalog.create_database("forbidden_db", False)
+        self.server.add_no_permission_database("forbidden_db")
+
+        with self.assertRaises(DatabaseNoPermissionException) as context:
+            self.rest_catalog.get_database("forbidden_db")
+
+        self.assertEqual("forbidden_db", context.exception.database)
+
+    def test_drop_database_no_permission(self):
+        self.rest_catalog.create_database("forbidden_db_drop", False)
+        self.server.add_no_permission_database("forbidden_db_drop")
+
+        with self.assertRaises(DatabaseNoPermissionException) as context:
+            self.rest_catalog.drop_database("forbidden_db_drop", False)
+
+        self.assertEqual("forbidden_db_drop", context.exception.database)
+
+    def test_list_tables_no_permission(self):
+        self.rest_catalog.create_database("forbidden_db_list", False)
+        self.server.add_no_permission_database("forbidden_db_list")
+
+        with self.assertRaises(DatabaseNoPermissionException) as context:
+            self.rest_catalog.list_tables("forbidden_db_list")
+
+        self.assertEqual("forbidden_db_list", context.exception.database)
+
+    def test_list_tables_paged_no_permission(self):
+        self.rest_catalog.create_database("forbidden_db_list_paged", False)
+        self.server.add_no_permission_database("forbidden_db_list_paged")
+
+        with self.assertRaises(DatabaseNoPermissionException) as context:
+            self.rest_catalog.list_tables_paged("forbidden_db_list_paged")
+
+        self.assertEqual("forbidden_db_list_paged", context.exception.database)
+
+    def test_get_table_no_permission(self):
+        self.rest_catalog.create_database("test_db", True)
+        schema = Schema.from_pyarrow_schema(self.pa_schema)
+        self.rest_catalog.create_table("test_db.forbidden_table", schema, 
False)
+
+        identifier = Identifier.create("test_db", "forbidden_table")
+        self.server.add_no_permission_table(identifier)
+
+        with self.assertRaises(TableNoPermissionException) as context:
+            self.rest_catalog.get_table("test_db.forbidden_table")
+
+        self.assertEqual("test_db.forbidden_table", 
context.exception.identifier.get_full_name())
+
+    def test_drop_table_no_permission(self):
+        self.rest_catalog.create_database("test_db_drop", True)
+        schema = Schema.from_pyarrow_schema(self.pa_schema)
+        self.rest_catalog.create_table("test_db_drop.forbidden_table", schema, 
False)
+
+        identifier = Identifier.create("test_db_drop", "forbidden_table")
+        self.server.add_no_permission_table(identifier)
+
+        with self.assertRaises(TableNoPermissionException) as context:
+            self.rest_catalog.drop_table("test_db_drop.forbidden_table", False)
+
+        self.assertEqual("test_db_drop.forbidden_table", 
context.exception.identifier.get_full_name())
+
+    def test_alter_table_no_permission(self):
+        self.rest_catalog.create_database("test_db_alter", True)
+        schema = Schema(
+            fields=[
+                DataField.from_dict({"id": 0, "name": "col1", "type": 
"STRING", "description": "field1"}),
+            ],
+            partition_keys=[],
+            primary_keys=[],
+            options={},
+            comment=""
+        )
+        self.rest_catalog.create_table("test_db_alter.forbidden_table", 
schema, False)
+
+        identifier = Identifier.create("test_db_alter", "forbidden_table")
+        self.server.add_no_permission_table(identifier)
+
+        with self.assertRaises(TableNoPermissionException) as context:
+            self.rest_catalog.alter_table(
+                "test_db_alter.forbidden_table",
+                [SchemaChange.add_column("col2", AtomicType("INT"))],
+                False
+            )
+
+        self.assertEqual("test_db_alter.forbidden_table", 
context.exception.identifier.get_full_name())
+
+    def test_commit_snapshot_no_permission(self):
+        self.rest_catalog.create_database("test_db_commit", True)
+        schema = Schema.from_pyarrow_schema(self.pa_schema)
+        self.rest_catalog.create_table("test_db_commit.forbidden_table", 
schema, False)
+
+        identifier = Identifier.create("test_db_commit", "forbidden_table")
+        self.server.add_no_permission_table(identifier)
+
+        test_snapshot = Snapshot(
+            version=3,
+            id=1,
+            schema_id=0,
+            base_manifest_list="manifest-list-base",
+            delta_manifest_list="manifest-list-delta",
+            total_record_count=100,
+            delta_record_count=100,
+            commit_user="test_user",
+            commit_identifier=1,
+            commit_kind="APPEND",
+            time_millis=int(time.time() * 1000)
+        )
+        test_statistics = [PartitionStatistics.create({"dt": "p1"}, 100, 1)]
+
+        with self.assertRaises(TableNoPermissionException) as context:
+            self.rest_catalog.commit_snapshot(
+                identifier,
+                "test-uuid",
+                test_snapshot,
+                test_statistics
+            )
+
+        self.assertEqual("test_db_commit.forbidden_table", 
context.exception.identifier.get_full_name())
+
+    def test_permission_after_create(self):
+        self.rest_catalog.create_database("test_perm_db", True)
+        schema = Schema.from_pyarrow_schema(self.pa_schema)
+        self.rest_catalog.create_table("test_perm_db.test_table", schema, 
False)
+
+        table = self.rest_catalog.get_table("test_perm_db.test_table")
+        self.assertEqual("test_perm_db.test_table", 
table.identifier.get_full_name())
+
+        tables = self.rest_catalog.list_tables("test_perm_db")
+        self.assertIn("test_table", tables)
+
+        identifier = Identifier.create("test_perm_db", "test_table")
+        self.server.add_no_permission_table(identifier)
+
+        with self.assertRaises(TableNoPermissionException):
+            self.rest_catalog.get_table("test_perm_db.test_table")
+
+    def test_drop_table_ignore_not_exists_with_no_permission(self):
+        self.rest_catalog.create_database("test_db_ignore", True)
+        schema = Schema.from_pyarrow_schema(self.pa_schema)
+        self.rest_catalog.create_table("test_db_ignore.forbidden_table", 
schema, False)
+
+        identifier = Identifier.create("test_db_ignore", "forbidden_table")
+        self.server.add_no_permission_table(identifier)
+
+        with self.assertRaises(TableNoPermissionException):
+            self.rest_catalog.drop_table("test_db_ignore.forbidden_table", 
True)
+
+    def test_drop_database_ignore_not_exists_with_no_permission(self):
+        self.rest_catalog.create_database("forbidden_db_ignore", False)
+        self.server.add_no_permission_database("forbidden_db_ignore")
+
+        with self.assertRaises(DatabaseNoPermissionException):
+            self.rest_catalog.drop_database("forbidden_db_ignore", True)

Reply via email to