This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new f12bcdbb8a [python] Add permission endpoints to the REST mock server
(#10503)
f12bcdbb8a is described below
commit f12bcdbb8a9de73e494c0652493db1f4231b6716
Author: Jiajia Li <[email protected]>
AuthorDate: Fri Oct 16 21:57:32 2026 +0800
[python] Add permission endpoints to the REST mock server (#10503)
---
.../tests/rest/rest_permission_server_test.py | 219 ++++++++++++++++
paimon-python/pypaimon/tests/rest/rest_server.py | 278 ++++++++++++++++++++-
.../pypaimon/tests/sql_permission_test.py | 161 ++++++++++++
3 files changed, 648 insertions(+), 10 deletions(-)
diff --git a/paimon-python/pypaimon/tests/rest/rest_permission_server_test.py
b/paimon-python/pypaimon/tests/rest/rest_permission_server_test.py
new file mode 100644
index 0000000000..51db1276d6
--- /dev/null
+++ b/paimon-python/pypaimon/tests/rest/rest_permission_server_test.py
@@ -0,0 +1,219 @@
+# 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 pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.api.api_response import ConfigResponse
+from pypaimon.api.auth import BearTokenAuthProvider
+from pypaimon.api.rest_exception import (AlreadyExistsException,
BadRequestException,
+ NoSuchResourceException)
+from pypaimon.catalog.catalog_exception import IllegalArgumentError,
TableNoPermissionException
+from pypaimon.common.identifier import Identifier
+from pypaimon.management.list_permissions_request import ListPermissionsRequest
+from pypaimon.management.permission_assignment import PermissionAssignment
+from pypaimon.management.permission_columns import PermissionColumns
+from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.resource_type import ResourceType
+from pypaimon.schema.schema_change import SchemaChange
+from pypaimon.tests.rest.rest_function_test import _mock_function
+from pypaimon.tests.rest.rest_server import RESTCatalogServer
+
+USERS = Identifier.create('default', 'users')
+MEMBERS = Identifier.create('default', 'members')
+COLUMNS = PermissionResource(ResourceType.COLUMN, 'default', 'users')
+ARROW_SCHEMA = pa.schema([('id', pa.int64()), ('email', pa.string()),
('phone', pa.string())])
+
+
+def _schema(query_auth=True):
+ return Schema.from_pyarrow_schema(ARROW_SCHEMA,
options={'query-auth.enabled': 'true'} if query_auth else {})
+
+
[email protected]
+def rest(tmp_path):
+ server = RESTCatalogServer(str(tmp_path), BearTokenAuthProvider('token'),
+ ConfigResponse(defaults={'prefix': 'mock'}),
'warehouse')
+ server.start()
+ try:
+ catalog = CatalogFactory.create({
+ 'metastore': 'rest', 'uri': server.get_url(), 'warehouse':
'warehouse',
+ 'token.provider': 'bear', 'token': 'token', 'data-token.enabled':
'false'})
+ catalog.create_database('default', False)
+ catalog.create_table('default.users', _schema(), False)
+ yield catalog, server
+ finally:
+ server.shutdown()
+
+
+def _table(name):
+ return PermissionResource(ResourceType.TABLE, 'default', name)
+
+
+def _grants(catalog, name, resource_type=ResourceType.TABLE):
+ grants = catalog.permission_management().list_permissions(
+ ListPermissionsRequest(resource_type, 'default', name)).elements
+ return [(grant.get_access(), grant.get_principal()) for grant in grants]
+
+
+def _column_grants(catalog, name='users'):
+ grants = catalog.permission_management().list_permissions(
+ ListPermissionsRequest(ResourceType.COLUMN, 'default', name)).elements
+ return [(grant.get_columns().get_column_names(),
grant.get_columns().get_excluded_column_names())
+ for grant in grants]
+
+
+def test_table_and_column_grants_follow_rename_and_drop(rest):
+ catalog, server = rest
+ permissions = catalog.permission_management()
+ permissions.grant_permission(PermissionAssignment(_table('users'),
'SELECT', 'analyst'))
+ permissions.grant_permission(PermissionAssignment(
+ COLUMNS, 'SELECT', 'analyst',
PermissionColumns(excluded_column_names=['email'])))
+ server.set_query_principals('analyst')
+
+ catalog.rename_table('default.users', 'default.members')
+ assert _grants(catalog, 'members') == [('SELECT', 'analyst')]
+ assert _column_grants(catalog, 'members') == [(None, ('email',))]
+ with pytest.raises(TableNoPermissionException):
+ catalog.auth_table_query(MEMBERS, ['email'])
+ catalog.create_table('default.users', _schema(), False)
+ assert [_grants(catalog, 'users'), _column_grants(catalog)] == [[], []]
+ catalog.auth_table_query(USERS, ['email'])
+
+ catalog.drop_table('default.members')
+ catalog.create_table('default.members', _schema(), False)
+ assert [_grants(catalog, 'members'), _column_grants(catalog, 'members')]
== [[], []]
+ catalog.auth_table_query(MEMBERS, ['email'])
+
+
+def test_dropping_a_database_or_function_removes_its_grants(rest):
+ catalog, server = rest
+ area = Identifier.create('default', 'area')
+ catalog.create_function(area, _mock_function(area), False)
+ catalog.create_database('sales', False)
+ for resource, access in ((PermissionResource(ResourceType.DATABASE,
'sales'), 'DESCRIBE'),
+ (PermissionResource(ResourceType.FUNCTION,
'default', function='area'), 'SELECT'),
+ (PermissionResource(ResourceType.DATABASE,
'default'), 'DESCRIBE')):
+
catalog.permission_management().grant_permission(PermissionAssignment(resource,
access, 'analyst'))
+
+ catalog.drop_database('sales')
+ catalog.drop_function(area, False)
+ assert [(grant.get_resource().get_type(),
grant.get_resource().get_database())
+ for grant in server.permission_store.values()] ==
[(ResourceType.DATABASE, 'default')]
+
+
[email protected]('expire_time, expired', [
+ (None, False),
+ ('2099-01-01T00:00:00.123000000Z', False),
+ ('2099-01-01t00:00:00.5z', False),
+ ('+12000-01-01T00:00:00Z', False),
+ ('2020-01-01T24:00:00.000000000Z', True),
+ ('-0001-01-01T00:00:00.999Z', True),
+])
+def test_column_grants_limit_the_selected_columns_until_they_expire(rest,
expire_time, expired):
+ catalog, server = rest
+ catalog.permission_management().grant_permission(PermissionAssignment(
+ COLUMNS, 'SELECT', 'analyst',
PermissionColumns(excluded_column_names=['email']), expire_time))
+
+ server.set_query_principals('analyst')
+ catalog.auth_table_query(USERS, ['id'])
+ for select in (['email'], None):
+ if expired:
+ catalog.auth_table_query(USERS, select)
+ else:
+ with pytest.raises(TableNoPermissionException):
+ catalog.auth_table_query(USERS, select)
+
+
[email protected]('columns', [PermissionColumns(column_names=['id']),
+
PermissionColumns(excluded_column_names=['email', 'phone'])])
+def test_column_grants_restrict_only_their_principal(rest, columns):
+ catalog, server = rest
+
catalog.permission_management().grant_permission(PermissionAssignment(COLUMNS,
'SELECT', 'analyst', columns))
+
+ server.set_query_principals('auditor')
+ catalog.auth_table_query(USERS, None)
+ server.set_query_principals('analyst')
+ catalog.auth_table_query(USERS, ['id'])
+ for select in (['email'], ['id', 'phone'], None):
+ with pytest.raises(TableNoPermissionException):
+ catalog.auth_table_query(USERS, select)
+
+
+def test_column_grants_need_query_auth_and_existing_columns(rest):
+ catalog, _ = rest
+ catalog.create_table('default.plain', _schema(query_auth=False), False)
+ with pytest.raises(AlreadyExistsException):
+ catalog.permission_management().grant_permission(PermissionAssignment(
+ PermissionResource(ResourceType.COLUMN, 'default', 'plain'),
'SELECT', 'analyst',
+ PermissionColumns(column_names=['id'])))
+ with pytest.raises(NoSuchResourceException):
+ catalog.permission_management().grant_permission(PermissionAssignment(
+ COLUMNS, 'SELECT', 'analyst',
PermissionColumns(column_names=['id', 'mobile'])))
+ assert [_column_grants(catalog, 'plain'), _column_grants(catalog)] == [[],
[]]
+
+ catalog.create_table('default.flagged', Schema.from_pyarrow_schema(
+ ARROW_SCHEMA, options={'query-auth.enabled': True}), False)
+ catalog.permission_management().grant_permission(PermissionAssignment(
+ PermissionResource(ResourceType.COLUMN, 'default', 'flagged'),
'SELECT', 'analyst',
+ PermissionColumns(column_names=['id'])))
+
+
+def test_column_grants_follow_column_renames(rest):
+ catalog, _ = rest
+ catalog.permission_management().grant_permission(PermissionAssignment(
+ COLUMNS, 'SELECT', 'analyst', PermissionColumns(column_names=['id',
'email'])))
+
+ catalog.alter_table('default.users', [SchemaChange.rename_column('email',
'contact')])
+ catalog.alter_table('default.users', [SchemaChange.drop_column('id')])
+ for change in (SchemaChange.drop_column('contact'),
SchemaChange.set_option('query-auth.enabled', 'false')):
+ with pytest.raises(BadRequestException):
+ catalog.alter_table('default.users', [change])
+
+ table = catalog.get_table('default.users')
+ for schema in (table.table_schema, table.schema_manager.latest()):
+ assert [field.name for field in schema.fields] == ['contact', 'phone']
+ assert schema.options['query-auth.enabled'] == 'true'
+ assert _column_grants(catalog) == [(('contact',), None)]
+
+
+def test_permission_listings_are_paged(rest):
+ catalog, _ = rest
+ for principal in ('analyst', 'auditor'):
+
catalog.permission_management().grant_permission(PermissionAssignment(_table('users'),
'SELECT', principal))
+
+ first = catalog.permission_management().list_permissions(
+ ListPermissionsRequest(ResourceType.TABLE, 'default', 'users',
max_results=1))
+ second =
catalog.permission_management().list_permissions(ListPermissionsRequest(
+ ResourceType.TABLE, 'default', 'users',
page_token=first.next_page_token, max_results=1))
+ assert [len(first.elements), len(second.elements), second.next_page_token]
== [1, 1, None]
+ assert [first.elements[0].get_principal(),
second.elements[0].get_principal()] == ['analyst', 'auditor']
+
+
+def test_tables_with_branches_cannot_have_column_grants(rest):
+ catalog, _ = rest
+ grant = PermissionAssignment(COLUMNS, 'SELECT', 'analyst',
PermissionColumns(excluded_column_names=['email']))
+ catalog.create_branch(USERS, 'b1')
+ with pytest.raises(BadRequestException):
+ catalog.permission_management().grant_permission(grant)
+ assert _column_grants(catalog) == []
+
+ catalog.drop_table('default.users')
+ catalog.create_table('default.users', _schema(), False)
+ catalog.permission_management().grant_permission(grant)
+ with pytest.raises(IllegalArgumentError):
+ catalog.create_branch(USERS, 'b1')
diff --git a/paimon-python/pypaimon/tests/rest/rest_server.py
b/paimon-python/pypaimon/tests/rest/rest_server.py
index f8ce233d69..db5a88c0a2 100755
--- a/paimon-python/pypaimon/tests/rest/rest_server.py
+++ b/paimon-python/pypaimon/tests/rest/rest_server.py
@@ -34,8 +34,10 @@ from pypaimon.api.api_request import (AlterDatabaseRequest,
AlterTableRequest,
CreateDatabaseRequest,
CreatePartitionsRequest,
CreateTableRequest, CreateTagRequest,
+ GrantPermissionRequest,
RenameBranchRequest,
- RenameTableRequest)
+ RenameTableRequest,
+ RevokePermissionRequest)
from pypaimon.api.api_response import (ConfigResponse,
CreatePartitionsResponse,
GetDatabaseResponse,
GetFunctionResponse,
@@ -45,7 +47,8 @@ from pypaimon.api.api_response import (ConfigResponse,
CreatePartitionsResponse,
ListFunctionDetailsResponse,
ListFunctionsGloballyResponse,
ListFunctionsResponse,
- ListPartitionsResponse,
ListTablesResponse,
+ ListPartitionsResponse,
+ ListPermissionsResponse,
ListTablesResponse,
ListTagsResponse,
PagedList, Partition,
RESTResponse, ErrorResponse)
@@ -68,6 +71,13 @@ from pypaimon.catalog.rest.table_metadata import
TableMetadata
from pypaimon.common.identifier import Identifier
from pypaimon.api.typedef import RESTAuthParameter
from pypaimon.common.json_util import JSON
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.management.permission_access import PermissionAccess
+from pypaimon.management.permission_assignment import (PermissionAssignment,
_INSTANT,
+ _days_from_civil)
+from pypaimon.management.permission_columns import PermissionColumns
+from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.resource_type import ResourceType
from pypaimon import Schema
from pypaimon.schema.schema_change import Actions, SchemaChange
from pypaimon.schema.schema_manager import SchemaManager
@@ -198,6 +208,66 @@ def _dict_to_schema_change(change_dict: dict) ->
SchemaChange:
raise ValueError(f"Unknown schema change action: {action}")
+def _cursor_key(*parts: Optional[str]) -> str:
+ return "".join("-1:" if part is None else "{}:{}".format(len(part), part)
for part in parts)
+
+
+def _permission_key(resource: PermissionResource, access: str, principal: str)
-> str:
+ return _cursor_key(resource.get_type().name, resource.get_database(),
resource.get_table(),
+ resource.get_function(), resource.get_view(),
+ PermissionAccess.canonicalize_for(resource, access),
principal)
+
+
+def _assignment_key(assignment: PermissionAssignment) -> str:
+ return _permission_key(
+ assignment.get_resource(), assignment.get_access(),
assignment.get_principal())
+
+
+def _resource_name(resource: PermissionResource) -> str:
+ name = resource.get_table() or resource.get_function() or
resource.get_view()
+ return ".".join(part for part in (resource.get_database(), name) if part)
or "catalog"
+
+
+def _granted_columns(assignment: PermissionAssignment) -> List[str]:
+ columns = assignment.get_columns()
+ names = columns.get_column_names()
+ return list(names if names is not None else
columns.get_excluded_column_names())
+
+
+def _expired(assignment: PermissionAssignment) -> bool:
+ expire_time = assignment.get_expire_time()
+ if expire_time is None:
+ return False
+ sign, digits, month, day, hour, minute, second, fraction =
_INSTANT.fullmatch(expire_time).groups()
+ hour, minute, second = int(hour), int(minute), int(second)
+ days = _days_from_civil(-int(digits) if sign == "-" else int(digits),
int(month), int(day))
+ if hour == 24:
+ days, hour = days + 1, 0
+ second = min(second, 59)
+ epoch_millis = ((days * 86400 + hour * 3600 + minute * 60 + second) * 1000
+ + int((fraction or "").ljust(9, "0")) // 1000000)
+ return epoch_millis <= int(time.time() * 1000)
+
+
+def _renamed_columns(previous: TableSchema, current: TableSchema) -> Dict[str,
Optional[str]]:
+ current_names = {field.id: field.name for field in current.fields}
+ return {field.name: current_names.get(field.id) for field in
previous.fields}
+
+
+def _query_auth_enabled(schema: TableSchema) -> bool:
+ return CoreOptions.from_dict(schema.options).query_auth_enabled
+
+
+def _on_table(resource: PermissionResource, identifier: Identifier) -> bool:
+ return resource.get_type() in (ResourceType.TABLE, ResourceType.COLUMN) \
+ and resource.get_database() == identifier.get_database_name() \
+ and resource.get_table() == identifier.get_table_name()
+
+
+def _matches(parameters: Dict[str, str], name: str, value: Optional[str]) ->
bool:
+ return name not in parameters or parameters[name] == value
+
+
class RESTCatalogServer:
"""Mock REST server for testing"""
@@ -226,6 +296,8 @@ class RESTCatalogServer:
self.no_permission_tables: List[str] = []
self.table_token_store: Dict[str, "RESTToken"] = {}
self.table_query_auth_store = {}
+ self.permission_store: Dict[str, PermissionAssignment] = {}
+ self.query_principals: set = set()
# Initialize mock catalog (simplified)
self.data_path = data_path
@@ -399,12 +471,18 @@ class RESTCatalogServer:
self.table_latest_snapshot_store,
self.table_partitions_store):
if source_name in store:
store[destination_name] = store.pop(source_name)
+ self._rename_table_access(source_table, destination_table)
return self._mock_response("", 200)
# Global functions endpoint (catalog-scoped)
if resource_path == self.resource_paths.functions() and method ==
"GET":
return self._functions_globally_handle(parameters)
+ if resource_path in (self.resource_paths.permissions(),
+ self.resource_paths.grant_permission(),
+ self.resource_paths.revoke_permission()):
+ return self._permissions_handle(method, resource_path, data,
parameters)
+
database = resource_path.split("/")[4]
# Database-specific endpoints
if
resource_path.startswith(self.resource_paths.database(database)):
@@ -591,6 +669,133 @@ class RESTCatalogServer:
return self._mock_response(ErrorResponse(None, None, "Not Found",
404), 404)
return self._mock_response(ErrorResponse(None, None, "Not Found",
404), 404)
+ def set_query_principals(self, *principals: str) -> None:
+ self.query_principals = set(principals)
+
+ def _permissions_handle(self, method: str, resource_path: str, data: str,
+ parameters: Dict[str, str]) -> Tuple[str, int]:
+ if method == "GET" and resource_path ==
self.resource_paths.permissions():
+ target = PermissionResource(
+ parameters.get("resourceType"), parameters.get("database"),
+ parameters.get("table"), parameters.get("function"),
parameters.get("view"))
+ missing = self._missing_permission_resource(target)
+ if missing is not None:
+ return missing
+ matched = [assignment for assignment in
self.permission_store.values()
+ if assignment.get_resource() == target
+ and _matches(parameters, "principal",
assignment.get_principal())
+ and _matches(parameters, "access",
assignment.get_access())]
+ return self._mock_response(ListPermissionsResponse(
+ *self._management_page({_assignment_key(grant): grant for
grant in matched}, parameters)), 200)
+ if method == "POST" and resource_path ==
self.resource_paths.grant_permission():
+ assignment = JSON.from_json(data,
GrantPermissionRequest).assignment()
+ missing =
self._missing_permission_resource(assignment.get_resource())
+ if missing is not None:
+ return missing
+ invalid = self._invalid_column_grant(assignment)
+ if invalid is not None:
+ return invalid
+ self.permission_store[_assignment_key(assignment)] = assignment
+ return self._mock_response("", 200)
+ if method == "POST" and resource_path ==
self.resource_paths.revoke_permission():
+ request = JSON.from_json(data, RevokePermissionRequest)
+ missing = self._missing_permission_resource(request.get_resource())
+ if missing is not None:
+ return missing
+ self.permission_store.pop(_permission_key(
+ request.get_resource(), request.get_access(),
request.get_principal()), None)
+ return self._mock_response("", 200)
+ return self._mock_response(ErrorResponse(None, None, "Not Found",
404), 404)
+
+ def _missing_permission_resource(
+ self, resource: PermissionResource) -> Optional[Tuple[str, int]]:
+ resource_type = resource.get_type()
+ database = resource.get_database()
+ if resource_type in (ResourceType.CATALOG, ResourceType.CATALOG_ALL):
+ return None
+ exists = database in self.database_store
+ if resource_type in (ResourceType.TABLE, ResourceType.COLUMN):
+ exists = exists and Identifier.create(
+ database, resource.get_table()).get_full_name() in
self.table_metadata_store
+ elif resource_type == ResourceType.FUNCTION:
+ exists = exists and "{}.{}".format(
+ database, resource.get_function()) in self.function_store
+ elif resource_type == ResourceType.VIEW:
+ exists = False
+ if exists:
+ return None
+ return self._mock_response(ErrorResponse(
+ resource_type.name, _resource_name(resource),
+ "Permission resource does not exist.", 404), 404)
+
+ def _invalid_column_grant(self, assignment: PermissionAssignment) ->
Optional[Tuple[str, int]]:
+ resource = assignment.get_resource()
+ if resource.get_type() != ResourceType.COLUMN:
+ return None
+ identifier = Identifier.create(resource.get_database(),
resource.get_table())
+ schema = self.table_metadata_store[identifier.get_full_name()].schema
+ if self.branch_store.get(identifier.get_full_name()):
+ return self._mock_response(ErrorResponse(
+ ErrorResponse.RESOURCE_TYPE_TABLE, _resource_name(resource),
+ "Column permissions are not supported on tables with
branches.", 400), 400)
+ if not _query_auth_enabled(schema):
+ return self._mock_response(ErrorResponse(
+ ErrorResponse.RESOURCE_TYPE_TABLE, _resource_name(resource),
+ "Column permissions require query-auth.enabled=true.", 409),
409)
+ names = {field.name for field in schema.fields}
+ for column in _granted_columns(assignment):
+ if column not in names:
+ return self._mock_response(ErrorResponse(
+ ErrorResponse.RESOURCE_TYPE_COLUMN, column,
+ "Permission column does not exist.", 404), 404)
+ return None
+
+ def _restricted(self, identifier: Identifier) -> bool:
+ return bool(self._column_grants(identifier))
+
+ def _column_grants(self, identifier: Identifier) ->
List[PermissionAssignment]:
+ return [assignment for assignment in self.permission_store.values()
+ if assignment.get_resource().get_type() == ResourceType.COLUMN
+ and _on_table(assignment.get_resource(), identifier)]
+
+ def _remove_permissions(self, matches) -> None:
+ for key in [key for key, assignment in self.permission_store.items()
+ if matches(assignment.get_resource())]:
+ del self.permission_store[key]
+
+ def _drop_table_access(self, identifier: Identifier) -> None:
+ for branch in self.branch_store.pop(identifier.get_full_name(), ()):
+ manager = self._schema_manager(identifier, branch)
+ manager.file_io.delete(manager.branch_path, recursive=True)
+ self._remove_permissions(lambda resource: _on_table(resource,
identifier))
+
+ def _rename_table_access(self, source: Identifier, destination:
Identifier) -> None:
+ moved = [assignment for assignment in self.permission_store.values()
+ if _on_table(assignment.get_resource(), source)]
+ self._remove_permissions(lambda resource: _on_table(resource, source))
+ for assignment in moved:
+ renamed = PermissionAssignment(
+ PermissionResource(assignment.get_resource().get_type(),
+ destination.get_database_name(),
destination.get_table_name()),
+ assignment.get_access(), assignment.get_principal(),
+ assignment.get_columns(), assignment.get_expire_time())
+ self.permission_store[_assignment_key(renamed)] = renamed
+
+ def _check_column_grants(self, identifier: Identifier, select:
Optional[List[str]]) -> None:
+ fields = [field.name for field in
self.table_metadata_store[identifier.get_full_name()].schema.fields]
+ grants = [grant for grant in self._column_grants(identifier)
+ if grant.get_principal() in self.query_principals and not
_expired(grant)]
+ if grants:
+ included = set(fields)
+ for grant in grants:
+ columns = set(_granted_columns(grant))
+ if grant.get_columns().get_column_names() is not None:
+ included &= columns
+ else:
+ included -= columns
+ if any(column not in included for column in (fields if select is
None else select)):
+ raise TableNoPermissionException(identifier)
+
# ======================= Function Handlers ===============================
def _functions_handle(self, method: str, data: str, database_name: str,
@@ -651,6 +856,10 @@ class RESTCatalogServer:
if key not in self.function_store:
raise FunctionNotExistException(identifier)
del self.function_store[key]
+ self._remove_permissions(
+ lambda resource: resource.get_type() == ResourceType.FUNCTION
+ and resource.get_database() == identifier.get_database_name()
+ and resource.get_function() == identifier.get_object_name())
return self._mock_response("", 200)
return self._mock_response(ErrorResponse(None, None, "Method Not
Allowed", 405), 405)
@@ -910,17 +1119,19 @@ class RESTCatalogServer:
if request.branch in store:
raise BranchAlreadyExistException(request.branch)
- schema_id =
self.table_metadata_store[identifier.get_full_name()].schema.id
+ metadata = self.table_metadata_store[identifier.get_full_name()]
+ schema_id, snapshot = metadata.schema.id, None
if request.from_tag is not None:
tags = self.tag_store.get(identifier.get_full_name(), {})
if request.from_tag not in tags:
raise TagNotExistException(request.from_tag)
snapshot = tags[request.from_tag].snapshot
- if snapshot is not None:
- schema_id = snapshot.schema_id
- self._write_snapshot_files(
- identifier, snapshot, None, request.branch)
+ schema_id = schema_id if snapshot is None else
snapshot.schema_id
+ if self._restricted(identifier):
+ raise ValueError("Tables with column permissions cannot have
branches.")
manager = self._schema_manager(identifier)
+ if snapshot is not None:
+ self._write_snapshot_files(identifier, snapshot, None,
request.branch)
target = manager.copy_with_branch(request.branch)
for schema in manager.list_all():
if schema.id <= schema_id:
@@ -1033,6 +1244,7 @@ class RESTCatalogServer:
elif method == "DELETE":
del self.database_store[database_name]
+ self._remove_permissions(lambda resource: resource.get_database()
== database_name)
return self._mock_response("", 200)
return self._mock_response(ErrorResponse(None, None, "Method Not
Allowed", 405), 405)
@@ -1091,6 +1303,7 @@ class RESTCatalogServer:
raise TableNotExistException(identifier)
else:
del self.table_metadata_store[identifier.get_full_name()]
+ self._drop_table_access(identifier)
if identifier.get_full_name() in self.table_latest_snapshot_store:
del
self.table_latest_snapshot_store[identifier.get_full_name()]
if identifier.get_full_name() in self.table_partitions_store:
@@ -1105,10 +1318,12 @@ class RESTCatalogServer:
return self._mock_response(ErrorResponse(None, None, "Method Not
Allowed", 405), 405)
if identifier.get_full_name() not in self.table_metadata_store:
raise TableNotExistException(identifier)
+ select = json.loads(data).get('select')
+ self._check_column_grants(identifier, select)
from pypaimon.api.api_response import AuthTableQueryResponse
response = self.table_query_auth_store.get(identifier.get_full_name(),
AuthTableQueryResponse())
if callable(response):
- response = response(json.loads(data).get('select'))
+ response = response(select)
return self._mock_response(response, 200)
def _table_token_handle(self, method: str, identifier: Identifier) ->
Tuple[str, int]:
@@ -1439,6 +1654,13 @@ class RESTCatalogServer:
raise ValueError(f"Invalid maxResults value:
{max_results_str}")
return DEFAULT_MAX_RESULTS
+ def _management_page(self, entities: Dict[str, Any],
+ parameters: Dict[str, str]) -> Tuple[List[Any],
Optional[str]]:
+ token = parameters.get(PAGE_TOKEN)
+ keys = sorted(key for key in entities if not token or key > token)
+ page = keys[:self._get_max_results(parameters)]
+ return [entities[key] for key in page], page[-1] if len(keys) >
len(page) else None
+
def _build_paged_entities(self, entities: List[Any], max_results: int,
page_token: Optional[str], desc: bool = False)
-> PagedList:
"""Build paged entities"""
@@ -1537,6 +1759,11 @@ class RESTCatalogServer:
schema_changes.append(change)
schema_manager = self._schema_manager(identifier, branch)
+ main = branch is None or branch == 'main'
+ base = Identifier.create(identifier.get_database_name(),
identifier.get_table_name())
+ if main:
+ self._validate_access_for_schema(
+ base, table_metadata,
schema_manager._generate_table_schema(table_metadata.schema, schema_changes))
new_schema = schema_manager.commit_changes(schema_changes)
updated_metadata = TableMetadata(
@@ -1544,10 +1771,41 @@ class RESTCatalogServer:
is_external=table_metadata.is_external,
uuid=table_metadata.uuid
)
- if branch is None or branch == 'main':
- base = Identifier.create(identifier.get_database_name(),
identifier.get_table_name())
+ if main:
+ self._evolve_column_grants(base, table_metadata.schema, new_schema)
self.table_metadata_store[base.get_full_name()] = updated_metadata
+ def _validate_access_for_schema(self, identifier: Identifier, metadata:
TableMetadata,
+ schema: TableSchema) -> None:
+ name = identifier.get_full_name()
+ grants = self._column_grants(identifier)
+ if grants and not _query_auth_enabled(schema):
+ raise ValueError(
+ "Cannot disable query-auth.enabled while table {} has column
permissions.".format(name))
+ renamed = _renamed_columns(metadata.schema, schema)
+ for grant in grants:
+ if grant.get_columns().get_column_names() is not None \
+ and not any(renamed.get(column) for column in
_granted_columns(grant)):
+ raise ValueError(
+ "Cannot drop every allowed column while table {} has
column permissions."
+ .format(name))
+
+ def _evolve_column_grants(self, identifier: Identifier, previous:
TableSchema,
+ current: TableSchema) -> None:
+ renamed = _renamed_columns(previous, current)
+ for grant in self._column_grants(identifier):
+ del self.permission_store[_assignment_key(grant)]
+ evolved = [renamed[column] for column in _granted_columns(grant)
if renamed.get(column)]
+ if not evolved:
+ continue
+ columns = (PermissionColumns(column_names=evolved)
+ if grant.get_columns().get_column_names() is not None
+ else PermissionColumns(excluded_column_names=evolved))
+ evolved_grant = PermissionAssignment(
+ grant.get_resource(), grant.get_access(),
grant.get_principal(),
+ columns, grant.get_expire_time())
+ self.permission_store[_assignment_key(evolved_grant)] =
evolved_grant
+
def _get_file_io(self):
"""Get FileIO instance for SchemaManager"""
from pypaimon.common.file_io import FileIO
diff --git a/paimon-python/pypaimon/tests/sql_permission_test.py
b/paimon-python/pypaimon/tests/sql_permission_test.py
new file mode 100644
index 0000000000..158f17f72a
--- /dev/null
+++ b/paimon-python/pypaimon/tests/sql_permission_test.py
@@ -0,0 +1,161 @@
+# 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 pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.api.api_response import ConfigResponse
+from pypaimon.api.auth import BearTokenAuthProvider
+from pypaimon.catalog.catalog_exception import TableNoPermissionException
+from pypaimon.management.list_permissions_request import ListPermissionsRequest
+from pypaimon.management.permission_assignment import PermissionAssignment
+from pypaimon.management.permission_columns import PermissionColumns
+from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.resource_type import ResourceType
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.schema.schema_change import SchemaChange
+from pypaimon.tests.rest.rest_server import RESTCatalogServer
+
+
+def _rust_version():
+ if not native_runtime_available():
+ return 0, 0
+ from importlib.metadata import version
+ return tuple(int(part) for part in version('pypaimon-rust').split('.')[:2])
+
+
+RUST_VERSION = _rust_version()
+pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
+ RUST_VERSION < (0, 4), reason='pypaimon-rust 0.4 or later required')]
+
+TOKEN = 'test-token'
+ANALYST = 'analyst'
+ROWS = [
+ {'id': 1, 'region': 'eu', 'email': '[email protected]'},
+ {'id': 2, 'region': 'us', 'email': '[email protected]'},
+ {'id': 3, 'region': 'eu', 'email': '[email protected]'},
+]
+ORDERS = PermissionResource(ResourceType.TABLE, 'default', 'orders')
+
+
[email protected]
+def clients(tmp_path):
+ from pypaimon_rust.datafusion import SQLContext
+
+ server = RESTCatalogServer(str(tmp_path), BearTokenAuthProvider(TOKEN),
+ ConfigResponse(defaults={'prefix':
'permission-test'}),
+ 'warehouse')
+ server.start()
+ try:
+ options = {'metastore': 'rest', 'uri': server.get_url(), 'warehouse':
'warehouse',
+ 'token.provider': 'bear', 'token': TOKEN,
'data-token.enabled': 'false'}
+ catalog = CatalogFactory.create(options)
+ catalog.create_database('default', True)
+ context = SQLContext()
+ context.register_catalog('paimon', options)
+ yield catalog, context, server
+ finally:
+ server.shutdown()
+
+
+def _create_orders(catalog, rows=()):
+ schema = pa.schema([('id', pa.int64()), ('region', pa.string()), ('email',
pa.string())])
+ catalog.create_table('default.orders', Schema.from_pyarrow_schema(schema),
False)
+ if rows:
+ table = catalog.get_table('default.orders')
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pylist(list(rows), schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ catalog.alter_table('default.orders',
[SchemaChange.set_option('query-auth.enabled', 'true')])
+
+
+def _sql(context, sql):
+ return [row for batch in context.sql(sql) for row in batch.to_pylist()]
+
+
+def _call_ok(context, sql):
+ assert _sql(context, sql) == [{'result': 'OK'}], sql
+
+
+def _python_rows(catalog, columns=('id', 'region', 'email')):
+ table = catalog.get_table('default.orders')
+ read_builder = table.new_read_builder().with_projection(list(columns))
+ splits = read_builder.new_scan().plan().splits()
+ return sorted(read_builder.new_read().to_arrow(splits).to_pylist(),
key=lambda row: row['id'])
+
+
+def _sql_rows(context, columns=('id', 'region', 'email')):
+ return _sql(context, 'SELECT {} FROM paimon.default.orders ORDER BY
id'.format(', '.join(columns)))
+
+
+def _grants(catalog, resource):
+ page =
catalog.permission_management().list_permissions(ListPermissionsRequest(
+ resource.get_type(), resource.get_database(), resource.get_table()))
+ return [(grant.get_access(), grant.get_principal(),
grant.get_expire_time(), grant.get_columns())
+ for grant in page.elements]
+
+
+def test_sql_grants_are_seen_by_pypaimon_and_back(clients):
+ catalog, context, _ = clients
+ _create_orders(catalog)
+ permissions = catalog.permission_management()
+
+ _call_ok(context, "CALL sys.grant_permission(resource_type => 'TABLE',
access => 'select', "
+ "principal => 'analyst', database => 'default', table =>
'orders', "
+ "expire_time => '2030-01-01T00:00:00Z')")
+ assert _grants(catalog, ORDERS) == [('SELECT', ANALYST,
'2030-01-01T00:00:00Z', None)]
+
+ columns = PermissionResource(ResourceType.COLUMN, 'default', 'orders')
+ permissions.grant_permission(PermissionAssignment(
+ columns, 'SELECT', ANALYST,
PermissionColumns(excluded_column_names=['email'])))
+ column_grant = {
+ 'resource_type': 'COLUMN', 'database': 'default', 'table': 'orders',
'function': None,
+ 'view': None, 'access': 'SELECT', 'principal': ANALYST,
'column_names': None,
+ 'excluded_column_names': 'email', 'expire_time': None,
'next_page_token': None}
+ assert _sql(context, "CALL sys.list_permissions(resource_type => 'COLUMN',
"
+ "database => 'default', table => 'orders')") ==
[column_grant]
+
+ _call_ok(context, "CALL sys.revoke_permission(resource_type => 'TABLE',
access => 'SELECT', "
+ "principal => 'analyst', database => 'default', table =>
'orders')")
+ permissions.revoke_permission(columns, 'SELECT', ANALYST)
+ assert _grants(catalog, ORDERS) == []
+ assert _sql(context, "CALL sys.list_permissions(resource_type => 'COLUMN',
"
+ "database => 'default', table => 'orders')") == []
+
+
[email protected](RUST_VERSION < (0, 5), reason='pypaimon-rust 0.5 or later
required')
+def test_column_grants_restrict_both_readers(clients):
+ catalog, context, server = clients
+ _create_orders(catalog, ROWS)
+ catalog.permission_management().grant_permission(PermissionAssignment(
+ PermissionResource(ResourceType.COLUMN, 'default', 'orders'),
'SELECT', ANALYST,
+ PermissionColumns(excluded_column_names=['email']),
'2099-01-01T00:00:00.123000000Z'))
+
+ server.set_query_principals(ANALYST)
+ allowed = [{'id': row['id'], 'region': row['region']} for row in ROWS]
+ assert _python_rows(catalog, ('id', 'region')) == allowed
+ assert _sql_rows(context, ('id', 'region')) == allowed
+ with pytest.raises(TableNoPermissionException):
+ _python_rows(catalog)
+ with pytest.raises(Exception, match='(?i)permission'):
+ _sql_rows(context)