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)