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 6c4da5c0b9 [python] Add REST permission management (#10322)
6c4da5c0b9 is described below
commit 6c4da5c0b9d29b5c173c603f707f1f7c575b4eea
Author: Jiajia Li <[email protected]>
AuthorDate: Sat Oct 3 17:04:28 2026 +0800
[python] Add REST permission management (#10322)
---
paimon-python/pypaimon/api/api_request.py | 76 ++++++
paimon-python/pypaimon/api/api_response.py | 37 +++
paimon-python/pypaimon/api/auth/dlf_signer.py | 6 +-
paimon-python/pypaimon/api/resource_paths.py | 15 +
paimon-python/pypaimon/api/rest_api.py | 46 ++++
.../pypaimon/api/rest_permission_management.py | 38 +++
.../pypaimon/catalog/rest/rest_catalog.py | 5 +
paimon-python/pypaimon/management/__init__.py | 16 ++
paimon-python/pypaimon/management/java_string.py | 27 ++
.../management/list_permissions_request.py | 98 +++++++
.../pypaimon/management/permission_access.py | 84 ++++++
.../pypaimon/management/permission_assignment.py | 170 ++++++++++++
.../pypaimon/management/permission_columns.py | 76 ++++++
.../pypaimon/management/permission_management.py | 38 +++
.../pypaimon/management/permission_resource.py | 120 ++++++++
paimon-python/pypaimon/management/resource_type.py | 38 +++
.../tests/permission_management_json_test.py | 303 +++++++++++++++++++++
.../pypaimon/tests/rest/dlf_signer_test.py | 13 +
.../tests/rest/rest_permission_management_test.py | 220 +++++++++++++++
19 files changed, 1423 insertions(+), 3 deletions(-)
diff --git a/paimon-python/pypaimon/api/api_request.py
b/paimon-python/pypaimon/api/api_request.py
index 8d17642f7a..c5ccf5399d 100644
--- a/paimon-python/pypaimon/api/api_request.py
+++ b/paimon-python/pypaimon/api/api_request.py
@@ -23,6 +23,10 @@ from pypaimon.common.identifier import Identifier
from pypaimon.common.json_util import json_field
from pypaimon.function.function_change import FunctionChange
from pypaimon.function.function_definition import FunctionDefinition
+from pypaimon.management.permission_access import PermissionAccess
+from pypaimon.management.permission_assignment import PermissionAssignment
+from pypaimon.management.permission_columns import PermissionColumns
+from pypaimon.management.permission_resource import PermissionResource
from pypaimon.schema.data_types import DataField
from pypaimon.schema.schema import Schema
from pypaimon.schema.schema_change import SchemaChange
@@ -212,3 +216,75 @@ class RenameBranchRequest(RESTRequest):
class ForwardBranchRequest(RESTRequest):
"""Empty body request; serializes to ``{}`` per Java
ForwardBranchRequest."""
pass
+
+
+class GrantPermissionRequest(RESTRequest):
+
+ def __init__(self, assignment: PermissionAssignment):
+ self._assignment = assignment
+
+ def assignment(self) -> PermissionAssignment:
+ return self._assignment
+
+ def get_resource(self) -> PermissionResource:
+ return self._assignment.get_resource()
+
+ def get_access(self) -> str:
+ return self._assignment.get_access()
+
+ def get_principal(self) -> str:
+ return self._assignment.get_principal()
+
+ def get_columns(self) -> Optional[PermissionColumns]:
+ return self._assignment.get_columns()
+
+ def get_expire_time(self) -> Optional[str]:
+ return self._assignment.get_expire_time()
+
+ def to_dict(self) -> dict:
+ return self._assignment.to_dict()
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "GrantPermissionRequest":
+ resource = data.get(PermissionAssignment.FIELD_RESOURCE)
+ columns = data.get(PermissionAssignment.FIELD_COLUMNS)
+ return cls(PermissionAssignment(
+ None if resource is None else
PermissionResource.from_dict(resource),
+ data.get(PermissionAssignment.FIELD_ACCESS),
+ data.get(PermissionAssignment.FIELD_PRINCIPAL),
+ None if columns is None else PermissionColumns.from_dict(columns),
+ data.get(PermissionAssignment.FIELD_EXPIRE_TIME)))
+
+
+class RevokePermissionRequest(RESTRequest):
+
+ FIELD_RESOURCE = "resource"
+ FIELD_ACCESS = "access"
+ FIELD_PRINCIPAL = "principal"
+
+ def __init__(self, resource: PermissionResource, access: str, principal:
str):
+ self._resource = resource
+ self._access = PermissionAccess.canonicalize_for(resource, access)
+ self._principal = PermissionAssignment.validate_principal(principal)
+
+ def get_resource(self) -> PermissionResource:
+ return self._resource
+
+ def get_access(self) -> str:
+ return self._access
+
+ def get_principal(self) -> str:
+ return self._principal
+
+ def to_dict(self) -> dict:
+ return {
+ self.FIELD_RESOURCE: self._resource.to_dict(),
+ self.FIELD_ACCESS: self._access,
+ self.FIELD_PRINCIPAL: self._principal,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "RevokePermissionRequest":
+ resource = data.get(cls.FIELD_RESOURCE)
+ return cls(None if resource is None else
PermissionResource.from_dict(resource),
+ data.get(cls.FIELD_ACCESS), data.get(cls.FIELD_PRINCIPAL))
diff --git a/paimon-python/pypaimon/api/api_response.py
b/paimon-python/pypaimon/api/api_response.py
index 1014895152..081ec4945f 100644
--- a/paimon-python/pypaimon/api/api_response.py
+++ b/paimon-python/pypaimon/api/api_response.py
@@ -23,6 +23,7 @@ from pypaimon.api.api_request import RESTRequest
from pypaimon.common.identifier import Identifier
from pypaimon.common.json_util import T, json_field, optional_json_field
from pypaimon.common.options import Options
+from pypaimon.management.permission_assignment import PermissionAssignment
from pypaimon.schema.data_types import DataField
from pypaimon.schema.schema import Schema
from pypaimon.snapshot.snapshot import Snapshot
@@ -628,3 +629,39 @@ class AuthTableQueryRequest(RESTRequest):
class AuthTableQueryResponse(RESTResponse):
filter: Optional[List[str]] = json_field("filter", default=None)
column_masking: Optional[Dict[str, str]] = json_field("columnMasking",
default=None)
+
+
+class ListPermissionsResponse(PagedResponse[PermissionAssignment]):
+
+ FIELD_PERMISSIONS = "permissions"
+
+ def __init__(self, permissions: Optional[List[PermissionAssignment]],
+ next_page_token: Optional[str] = None):
+ self._permissions = permissions
+ self._next_page_token = next_page_token
+
+ def get_permissions(self) -> Optional[List[PermissionAssignment]]:
+ return self._permissions
+
+ def data(self) -> Optional[List[PermissionAssignment]]:
+ return self._permissions
+
+ def get_next_page_token(self) -> Optional[str]:
+ return self._next_page_token
+
+ def to_dict(self) -> dict:
+ result = {self.FIELD_PERMISSIONS: None if self._permissions is None
else [
+ None if permission is None else permission.to_dict()
+ for permission in self._permissions]}
+ if self._next_page_token is not None:
+ result[PagedResponse.FIELD_NEXT_PAGE_TOKEN] = self._next_page_token
+ return result
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "ListPermissionsResponse":
+ permissions = data.get(cls.FIELD_PERMISSIONS)
+ return cls(
+ None if permissions is None else [
+ None if permission is None else
PermissionAssignment.from_dict(permission)
+ for permission in permissions],
+ data.get(PagedResponse.FIELD_NEXT_PAGE_TOKEN))
diff --git a/paimon-python/pypaimon/api/auth/dlf_signer.py
b/paimon-python/pypaimon/api/auth/dlf_signer.py
index fa69c16f6b..b57eeb0652 100644
--- a/paimon-python/pypaimon/api/auth/dlf_signer.py
+++ b/paimon-python/pypaimon/api/auth/dlf_signer.py
@@ -25,7 +25,7 @@ from abc import ABC, abstractmethod
from collections import OrderedDict
from datetime import datetime, timezone
from typing import Dict, NamedTuple, Optional
-from urllib.parse import quote, unquote
+from urllib.parse import quote, unquote, unquote_plus
from pypaimon.api.auth.dlf_openapi_actions import resolve_action
from pypaimon.api.token_loader import DLFToken
@@ -416,8 +416,8 @@ class DLFOpenApiSigner(DLFRequestSigner):
return "".join(sb)
def _build_canonicalized_resource(self, rest_auth_parameter:
RESTAuthParameter) -> str:
- # Decode the path and use the original unencoded path for signature
calculation
- path = unquote(rest_auth_parameter.path)
+ # Decode like Java's URLDecoder, which also turns the prefix's '+'
back into a space.
+ path = unquote_plus(rest_auth_parameter.path)
params = rest_auth_parameter.parameters
if not params:
diff --git a/paimon-python/pypaimon/api/resource_paths.py
b/paimon-python/pypaimon/api/resource_paths.py
index 9207d4266e..94f61948a3 100644
--- a/paimon-python/pypaimon/api/resource_paths.py
+++ b/paimon-python/pypaimon/api/resource_paths.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import urllib.parse
from typing import Optional
from pypaimon.api.rest_util import RESTUtil
@@ -34,8 +35,13 @@ class ResourcePaths:
BRANCHES = "branches"
RENAME = "rename"
FORWARD = "forward"
+ PERMISSIONS = "permissions"
def __init__(self, prefix: str):
+ # Java encodes the prefix once with URLEncoder, so it stays one path
segment. '~' stays raw:
+ # requests un-escapes %7E before sending, and the signature must cover
the path sent.
+ if prefix:
+ prefix = urllib.parse.quote_plus(prefix, safe="*~",
errors="replace")
self.base_path = "/{}/{}".format(self.V1, prefix).rstrip("/")
@classmethod
@@ -139,3 +145,12 @@ class ResourcePaths:
self.base_path, self.DATABASES,
RESTUtil.encode_string(database_name),
self.TABLES, RESTUtil.encode_string(table_name)
)
+
+ def permissions(self) -> str:
+ return "{}/{}".format(self.base_path, self.PERMISSIONS)
+
+ def grant_permission(self) -> str:
+ return "{}/grant".format(self.permissions())
+
+ def revoke_permission(self) -> str:
+ return "{}/revoke".format(self.permissions())
diff --git a/paimon-python/pypaimon/api/rest_api.py
b/paimon-python/pypaimon/api/rest_api.py
index f9ecb27faa..608b195974 100755
--- a/paimon-python/pypaimon/api/rest_api.py
+++ b/paimon-python/pypaimon/api/rest_api.py
@@ -26,7 +26,9 @@ from pypaimon.api.api_request import (AlterDatabaseRequest,
AlterFunctionRequest
CreateFunctionRequest,
CreatePartitionsRequest,
CreateTableRequest, CreateTagRequest,
ForwardBranchRequest,
+ GrantPermissionRequest,
RenameBranchRequest, RenameTableRequest,
+ RevokePermissionRequest,
RollbackTableRequest)
from pypaimon.api.api_response import (CommitTableResponse, ConfigResponse,
GetDatabaseResponse,
GetFunctionResponse,
@@ -37,6 +39,7 @@ from pypaimon.api.api_response import (CommitTableResponse,
ConfigResponse,
ListFunctionDetailsResponse,
ListFunctionsGloballyResponse,
ListFunctionsResponse,
+ ListPermissionsResponse,
CreatePartitionsResponse,
ListPartitionsResponse,
ListTablesResponse, ListTagsResponse,
@@ -54,6 +57,10 @@ from pypaimon.common import user_agent
from pypaimon.common.options import Options
from pypaimon.common.options.config import CatalogOptions
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_resource import PermissionResource
from pypaimon.schema.schema import Schema
from pypaimon.snapshot.snapshot import Snapshot
from pypaimon.snapshot.snapshot_commit import PartitionStatistics
@@ -730,6 +737,45 @@ class RESTApi:
self.rest_auth_function,
)
+ def list_permissions(self, request: ListPermissionsRequest) ->
ListPermissionsResponse:
+ query_params = {}
+ RESTApi._put_query_parameter(query_params, "resourceType",
request.get_resource_type().name)
+ RESTApi._put_query_parameter(query_params, "database",
request.get_database())
+ RESTApi._put_query_parameter(query_params, "table",
request.get_table())
+ RESTApi._put_query_parameter(query_params, "function",
request.get_function())
+ RESTApi._put_query_parameter(query_params, "view", request.get_view())
+ RESTApi._put_query_parameter(query_params, "principal",
request.get_principal())
+ RESTApi._put_query_parameter(query_params, "access",
request.get_access())
+ if request.get_max_results() is not None:
+ query_params[RESTApi.MAX_RESULTS] = str(request.get_max_results())
+ RESTApi._put_query_parameter(query_params, RESTApi.PAGE_TOKEN,
request.get_page_token())
+ return self.client.get_with_params(
+ self.resource_paths.permissions(),
+ query_params,
+ ListPermissionsResponse,
+ self.rest_auth_function,
+ )
+
+ def grant_permission(self, assignment: PermissionAssignment) -> None:
+ self.client.post(
+ self.resource_paths.grant_permission(),
+ GrantPermissionRequest(assignment),
+ self.rest_auth_function,
+ )
+
+ def revoke_permission(self, resource: PermissionResource, access: str,
principal: str) -> None:
+ self.client.post(
+ self.resource_paths.revoke_permission(),
+ RevokePermissionRequest(resource, access, principal),
+ self.rest_auth_function,
+ )
+
+ @staticmethod
+ def _put_query_parameter(query_params: Dict[str, str], name: str, value:
Optional[str]):
+ # Java isNotEmpty: unlike __build_paged_query_params, a whitespace
value is sent.
+ if value:
+ query_params[name] = value
+
@staticmethod
def __validate_identifier(identifier: Identifier):
if not identifier:
diff --git a/paimon-python/pypaimon/api/rest_permission_management.py
b/paimon-python/pypaimon/api/rest_permission_management.py
new file mode 100644
index 0000000000..7a6734974f
--- /dev/null
+++ b/paimon-python/pypaimon/api/rest_permission_management.py
@@ -0,0 +1,38 @@
+# 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.
+from pypaimon.api.api_response import PagedList
+from pypaimon.management.list_permissions_request import \
+ ListPermissionsRequest
+from pypaimon.management.permission_assignment import PermissionAssignment
+from pypaimon.management.permission_management import PermissionManagement
+from pypaimon.management.permission_resource import PermissionResource
+
+
+class RESTPermissionManagement(PermissionManagement):
+
+ def __init__(self, api):
+ self.api = api
+
+ def list_permissions(self, request: ListPermissionsRequest) ->
PagedList[PermissionAssignment]:
+ response = self.api.list_permissions(request)
+ return PagedList(response.get_permissions(),
response.get_next_page_token())
+
+ def grant_permission(self, assignment: PermissionAssignment) -> None:
+ self.api.grant_permission(assignment)
+
+ def revoke_permission(self, resource: PermissionResource, access: str,
principal: str) -> None:
+ self.api.revoke_permission(resource, access, principal)
diff --git a/paimon-python/pypaimon/catalog/rest/rest_catalog.py
b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
index 6f6b72d043..c8ebbfa10e 100644
--- a/paimon-python/pypaimon/catalog/rest/rest_catalog.py
+++ b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
@@ -19,6 +19,7 @@ import logging
from typing import Any, Callable, Dict, List, Optional, Union
from pypaimon.api.api_response import ErrorResponse, GetTableResponse,
GetTagResponse, PagedList, Partition
from pypaimon.api.rest_api import RESTApi
+from pypaimon.api.rest_permission_management import RESTPermissionManagement
from pypaimon.catalog.catalog_exception import IllegalArgumentError,
IllegalStateError
from pypaimon.api.rest_exception import (NoSuchResourceException,
AlreadyExistsException,
ForbiddenException,
BadRequestException,
@@ -46,6 +47,7 @@ from pypaimon.common.file_io import FileIO
from pypaimon.filesystem.caching_file_io import CachingFileIO
from pypaimon.common.identifier import Identifier
from pypaimon.common.json_util import JSON
+from pypaimon.management.permission_management import PermissionManagement
from pypaimon.schema.schema import Schema
from pypaimon.schema.schema_change import SchemaChange
from pypaimon.schema.table_schema import TableSchema
@@ -92,6 +94,9 @@ class RESTCatalog(Catalog):
from pypaimon.catalog.rest.rest_catalog_loader import RESTCatalogLoader
return RESTCatalogLoader(self.context)
+ def permission_management(self) -> PermissionManagement:
+ return RESTPermissionManagement(self.rest_api)
+
def supports_version_management(self) -> bool:
"""
Return whether this catalog supports version management for tables.
diff --git a/paimon-python/pypaimon/management/__init__.py
b/paimon-python/pypaimon/management/__init__.py
new file mode 100644
index 0000000000..13a83393a9
--- /dev/null
+++ b/paimon-python/pypaimon/management/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/paimon-python/pypaimon/management/java_string.py
b/paimon-python/pypaimon/management/java_string.py
new file mode 100644
index 0000000000..ba92ab453c
--- /dev/null
+++ b/paimon-python/pypaimon/management/java_string.py
@@ -0,0 +1,27 @@
+# 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.
+from typing import Optional
+
+
+def is_blank(value: Optional[str]) -> bool:
+ """Java ``trim().isEmpty()``: only characters <= U+0020 are blank, unlike
``str.strip()``."""
+ return value is None or all(ch <= ' ' for ch in value)
+
+
+def java_length(value: str) -> int:
+ """Java ``String.length()``: UTF-16 code units, not code points."""
+ return len(value.encode('utf-16-le', 'surrogatepass')) // 2
diff --git a/paimon-python/pypaimon/management/list_permissions_request.py
b/paimon-python/pypaimon/management/list_permissions_request.py
new file mode 100644
index 0000000000..9abdc7ef36
--- /dev/null
+++ b/paimon-python/pypaimon/management/list_permissions_request.py
@@ -0,0 +1,98 @@
+# 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.
+from typing import Optional
+
+from pypaimon.management.java_string import is_blank
+from pypaimon.management.permission_access import PermissionAccess
+from pypaimon.management.permission_assignment import PermissionAssignment
+from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.resource_type import ResourceType
+
+
+class ListPermissionsRequest:
+
+ MAX_PAGE_SIZE = 1000
+
+ def __init__(self,
+ resource_type: ResourceType,
+ database: Optional[str] = None,
+ table: Optional[str] = None,
+ function: Optional[str] = None,
+ view: Optional[str] = None,
+ principal: Optional[str] = None,
+ access: Optional[str] = None,
+ page_token: Optional[str] = None,
+ max_results: Optional[int] = None):
+ self._resource = _exact_resource(resource_type, database, table,
function, view)
+ if not is_blank(principal):
+ PermissionAssignment.validate_principal(principal)
+ if max_results is not None and max_results <= 0:
+ raise ValueError("maxResults must be greater than 0.")
+ if max_results is not None and max_results > self.MAX_PAGE_SIZE:
+ raise ValueError("maxResults must be at most
{}.".format(self.MAX_PAGE_SIZE))
+ self._principal = None if is_blank(principal) else principal
+ self._access = (None if is_blank(access)
+ else PermissionAccess.canonicalize_for(self._resource,
access))
+ self._page_token = page_token
+ self._max_results = max_results
+
+ def get_resource_type(self) -> ResourceType:
+ return self._resource.get_type()
+
+ def get_database(self) -> Optional[str]:
+ return self._resource.get_database()
+
+ def get_table(self) -> Optional[str]:
+ return self._resource.get_table()
+
+ def get_function(self) -> Optional[str]:
+ return self._resource.get_function()
+
+ def get_view(self) -> Optional[str]:
+ return self._resource.get_view()
+
+ def get_principal(self) -> Optional[str]:
+ return self._principal
+
+ def get_access(self) -> Optional[str]:
+ return self._access
+
+ def get_page_token(self) -> Optional[str]:
+ return self._page_token
+
+ def get_max_results(self) -> Optional[int]:
+ return self._max_results
+
+ def resource(self) -> PermissionResource:
+ return self._resource
+
+ def with_page_token(self, new_page_token: Optional[str]) ->
"ListPermissionsRequest":
+ resource = self._resource
+ return ListPermissionsRequest(
+ resource.get_type(), resource.get_database(), resource.get_table(),
+ resource.get_function(), resource.get_view(), self._principal,
self._access,
+ new_page_token, self._max_results)
+
+
+def _exact_resource(resource_type, database, table, function, view) ->
PermissionResource:
+ if resource_type is None:
+ raise ValueError("resourceType cannot be null")
+ try:
+ return PermissionResource(resource_type, database, table, function,
view)
+ except ValueError as e:
+ raise ValueError(
+ "Permission listing requires an exact target resource:
{}".format(e)) from e
diff --git a/paimon-python/pypaimon/management/permission_access.py
b/paimon-python/pypaimon/management/permission_access.py
new file mode 100644
index 0000000000..e9756946b1
--- /dev/null
+++ b/paimon-python/pypaimon/management/permission_access.py
@@ -0,0 +1,84 @@
+# 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.
+from typing import FrozenSet
+
+from pypaimon.management.java_string import is_blank, java_length
+from pypaimon.management.resource_type import ResourceType
+
+
+class PermissionAccess:
+ MAX_LENGTH = 32
+
+ ALL = "ALL"
+ CREATEDATABASE = "CREATEDATABASE"
+ DESCRIBE = "DESCRIBE"
+ ALTER = "ALTER"
+ DROP = "DROP"
+ CREATETABLE = "CREATETABLE"
+ CREATEFUNCTION = "CREATEFUNCTION"
+ CREATEVIEW = "CREATEVIEW"
+ LIST = "LIST"
+ SELECT = "SELECT"
+ UPDATE = "UPDATE"
+ GRANT = "GRANT"
+
+ _BUILT_INS = {
+ ResourceType.CATALOG: frozenset((ALL, ALTER, DROP, GRANT,
CREATEDATABASE)),
+ ResourceType.CATALOG_ALL: frozenset((
+ ALL, DESCRIBE, ALTER, DROP, GRANT, CREATETABLE, CREATEVIEW,
CREATEFUNCTION, LIST,
+ SELECT, UPDATE)),
+ ResourceType.DATABASE: frozenset((
+ ALL, DESCRIBE, ALTER, DROP, GRANT, CREATETABLE, CREATEVIEW,
CREATEFUNCTION, LIST)),
+ ResourceType.DATABASE_ALL: frozenset((ALL, SELECT, UPDATE, ALTER,
DROP, GRANT)),
+ ResourceType.TABLE: frozenset((ALL, SELECT, UPDATE, ALTER, DROP,
GRANT)),
+ ResourceType.COLUMN: frozenset((SELECT,)),
+ ResourceType.VIEW: frozenset((ALL, SELECT, ALTER, DROP, GRANT)),
+ ResourceType.FUNCTION: frozenset((ALL, SELECT, ALTER, DROP, GRANT)),
+ }
+
+ @staticmethod
+ def canonicalize(access: str) -> str:
+ if is_blank(access):
+ raise ValueError("access cannot be empty.")
+ if java_length(access) > PermissionAccess.MAX_LENGTH:
+ raise ValueError(
+ "access must contain at most {}
characters.".format(PermissionAccess.MAX_LENGTH))
+ # Upper-cased but not trimmed, as in Java.
+ canonical = access.upper()
+ if java_length(canonical) > PermissionAccess.MAX_LENGTH:
+ raise ValueError(
+ "access must contain at most {} characters after
canonicalization."
+ .format(PermissionAccess.MAX_LENGTH))
+ if any(canonical in accesses for accesses in
PermissionAccess._BUILT_INS.values()):
+ return canonical
+ raise ValueError("Unknown access '{}'.".format(canonical))
+
+ @staticmethod
+ def canonicalize_for(resource, access: str) -> str:
+ if resource is None:
+ raise ValueError("resource cannot be null")
+ canonical = PermissionAccess.canonicalize(access)
+ if canonical not in PermissionAccess._BUILT_INS[resource.get_type()]:
+ raise ValueError(
+ "Access '{}' is not valid for {}.".format(canonical,
resource.get_type().name))
+ return canonical
+
+ @staticmethod
+ def built_ins(resource_type: ResourceType) -> FrozenSet[str]:
+ if resource_type is None:
+ raise ValueError("resource type cannot be null")
+ return PermissionAccess._BUILT_INS[resource_type]
diff --git a/paimon-python/pypaimon/management/permission_assignment.py
b/paimon-python/pypaimon/management/permission_assignment.py
new file mode 100644
index 0000000000..2c0ef40c2f
--- /dev/null
+++ b/paimon-python/pypaimon/management/permission_assignment.py
@@ -0,0 +1,170 @@
+# 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 re
+from typing import Any, Dict, Optional
+
+from pypaimon.management.java_string import is_blank, java_length
+from pypaimon.management.permission_access import PermissionAccess
+from pypaimon.management.permission_columns import PermissionColumns
+from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.resource_type import ResourceType
+
+# Java Instant.parse on JDK 8/11 (ISO_INSTANT): case-insensitive, ASCII
digits, Z offset only.
+_INSTANT = re.compile(
+
r"([+-]?)([0-9]{4,10})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})"
+ r"(?:\.([0-9]{0,9}))?[Zz]")
+# Instant.MIN and Instant.MAX, in epoch seconds.
+_MIN_SECOND = -31557014167219200
+_MAX_SECOND = 31556889864403199
+
+
+class PermissionAssignment:
+ MAX_PRINCIPAL_LENGTH = 128
+
+ FIELD_RESOURCE = "resource"
+ FIELD_ACCESS = "access"
+ FIELD_PRINCIPAL = "principal"
+ FIELD_COLUMNS = "columns"
+ FIELD_EXPIRE_TIME = "expireTime"
+
+ def __init__(self,
+ resource: PermissionResource,
+ access: str,
+ principal: str,
+ columns: Optional[PermissionColumns] = None,
+ expire_time: Optional[str] = None):
+ if resource is None:
+ raise ValueError("resource cannot be null")
+ self._resource = resource
+ self._access = PermissionAccess.canonicalize_for(resource, access)
+ self._principal = PermissionAssignment.validate_principal(principal)
+ if resource.get_type() == ResourceType.COLUMN:
+ if columns is None:
+ raise ValueError("columns is required for COLUMN resource.")
+ elif columns is not None:
+ raise ValueError("columns is only valid for COLUMN resource.")
+ _validate_expire_time(expire_time)
+ self._columns = columns
+ self._expire_time = expire_time
+
+ @staticmethod
+ def validate_principal(principal: str) -> str:
+ if is_blank(principal):
+ raise ValueError("principal cannot be empty.")
+ if java_length(principal) > PermissionAssignment.MAX_PRINCIPAL_LENGTH:
+ raise ValueError("principal must contain at most {}
characters.".format(
+ PermissionAssignment.MAX_PRINCIPAL_LENGTH))
+ return principal
+
+ def get_resource(self) -> PermissionResource:
+ return self._resource
+
+ def get_access(self) -> str:
+ return self._access
+
+ def get_principal(self) -> str:
+ return self._principal
+
+ def get_columns(self) -> Optional[PermissionColumns]:
+ return self._columns
+
+ def get_expire_time(self) -> Optional[str]:
+ return self._expire_time
+
+ def to_dict(self) -> Dict[str, Any]:
+ result = {
+ self.FIELD_RESOURCE: None if self._resource is None else
self._resource.to_dict(),
+ self.FIELD_ACCESS: self._access,
+ self.FIELD_PRINCIPAL: self._principal,
+ }
+ if self._columns is not None:
+ result[self.FIELD_COLUMNS] = self._columns.to_dict()
+ if self._expire_time is not None:
+ result[self.FIELD_EXPIRE_TIME] = self._expire_time
+ return result
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "PermissionAssignment":
+ """Unvalidated, like Java's Jackson constructor; the nested resource
and columns validate."""
+ resource = data.get(cls.FIELD_RESOURCE)
+ columns = data.get(cls.FIELD_COLUMNS)
+ expire_time = data.get(cls.FIELD_EXPIRE_TIME)
+ # Java's (String) cast rejects any other type.
+ if expire_time is not None and not isinstance(expire_time, str):
+ raise TypeError("expireTime must be a string, got
{!r}.".format(expire_time))
+ assignment = cls.__new__(cls)
+ assignment._resource = None if resource is None else
PermissionResource.from_dict(resource)
+ assignment._access = data.get(cls.FIELD_ACCESS)
+ assignment._principal = data.get(cls.FIELD_PRINCIPAL)
+ assignment._columns = None if columns is None else
PermissionColumns.from_dict(columns)
+ assignment._expire_time = expire_time
+ return assignment
+
+
+def _validate_expire_time(expire_time: Optional[str]):
+ if expire_time is None:
+ return
+ nanos = _parse_instant(expire_time)
+ if nanos is None:
+ raise ValueError("expireTime must be an ISO-8601 UTC instant.")
+ if nanos % 1_000_000 != 0:
+ raise ValueError("expireTime must have at most millisecond precision.")
+
+
+def _parse_instant(text: str) -> Optional[int]:
+ """The nano-of-second of ``text`` if Java's Instant.parse accepts it, else
None."""
+ match = _INSTANT.fullmatch(text)
+ if match is None:
+ return None
+ sign, digits = match.group(1), match.group(2)
+ # Strict EXCEEDS_PAD: no -0000, and '+' exactly when there are more than
four digits.
+ if sign == "-":
+ if int(digits) == 0:
+ return None
+ elif (sign == "+") != (len(digits) > 4):
+ return None
+ year = -int(digits) if sign == "-" else int(digits)
+ month, day, hour, minute, second = (int(group) for group in match.group(3,
4, 5, 6, 7))
+ nanos = int((match.group(8) or "").ljust(9, "0"))
+ days = 0
+ # ISO_INSTANT reads 24:00:00 as the next midnight and folds a leap second
into :59.
+ if (hour, minute, second, nanos) == (24, 0, 0, 0):
+ hour, days = 0, 1
+ elif (hour, minute, second) == (23, 59, 60):
+ second = 59
+ if not (1 <= month <= 12 and 1 <= day <= _days_in_month(year, month)
+ and hour <= 23 and minute <= 59 and second <= 59):
+ return None
+ epoch_second = ((_days_from_civil(year, month, day) + days) * 86400
+ + hour * 3600 + minute * 60 + second)
+ return nanos if _MIN_SECOND <= epoch_second <= _MAX_SECOND else None
+
+
+def _days_in_month(year: int, month: int) -> int:
+ if month == 2:
+ return 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
else 28
+ return 30 if month in (4, 6, 9, 11) else 31
+
+
+def _days_from_civil(year: int, month: int, day: int) -> int:
+ """Days since 1970-01-01 in the proleptic Gregorian calendar, for any
year."""
+ year -= month <= 2
+ era = year // 400
+ year_of_era = year - era * 400
+ day_of_year = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
+ day_of_era = year_of_era * 365 + year_of_era // 4 - year_of_era // 100 +
day_of_year
+ return era * 146097 + day_of_era - 719468
diff --git a/paimon-python/pypaimon/management/permission_columns.py
b/paimon-python/pypaimon/management/permission_columns.py
new file mode 100644
index 0000000000..65c3dd1dff
--- /dev/null
+++ b/paimon-python/pypaimon/management/permission_columns.py
@@ -0,0 +1,76 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from typing import Any, Dict, Iterable, Optional, Tuple
+
+from pypaimon.management.java_string import is_blank
+
+
+class PermissionColumns:
+ FIELD_COLUMN_NAMES = "columnNames"
+ FIELD_EXCLUDED_COLUMN_NAMES = "excludedColumnNames"
+
+ def __init__(self,
+ column_names: Optional[Iterable[str]] = None,
+ excluded_column_names: Optional[Iterable[str]] = None):
+ if (column_names is None) == (excluded_column_names is None):
+ raise ValueError(
+ "columns must contain exactly one of columnNames or
excludedColumnNames.")
+ self._column_names = _immutable_non_empty(column_names,
self.FIELD_COLUMN_NAMES)
+ self._excluded_column_names = _immutable_non_empty(
+ excluded_column_names, self.FIELD_EXCLUDED_COLUMN_NAMES)
+
+ def get_column_names(self) -> Optional[Tuple[str, ...]]:
+ return self._column_names
+
+ def get_excluded_column_names(self) -> Optional[Tuple[str, ...]]:
+ return self._excluded_column_names
+
+ def to_dict(self) -> Dict[str, Any]:
+ if self._column_names is not None:
+ return {self.FIELD_COLUMN_NAMES: list(self._column_names)}
+ return {self.FIELD_EXCLUDED_COLUMN_NAMES:
list(self._excluded_column_names)}
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "PermissionColumns":
+ return cls(data.get(cls.FIELD_COLUMN_NAMES),
data.get(cls.FIELD_EXCLUDED_COLUMN_NAMES))
+
+ def __eq__(self, other):
+ if not isinstance(other, PermissionColumns):
+ return False
+ return (self._column_names == other._column_names
+ and self._excluded_column_names ==
other._excluded_column_names)
+
+ def __hash__(self):
+ return hash((self._column_names, self._excluded_column_names))
+
+
+def _immutable_non_empty(columns: Optional[Iterable[str]],
+ field_name: str) -> Optional[Tuple[str, ...]]:
+ if columns is None:
+ return None
+ # A string would split into single-letter column names.
+ if isinstance(columns, str):
+ raise TypeError("{} must be a list of column names, not a
string.".format(field_name))
+ columns = tuple(columns)
+ if not columns:
+ raise ValueError("{} cannot be empty.".format(field_name))
+ for column in columns:
+ if is_blank(column):
+ raise ValueError("{} cannot contain an empty column
name.".format(field_name))
+ if len(set(columns)) != len(columns):
+ raise ValueError("{} cannot contain duplicate column
names.".format(field_name))
+ return columns
diff --git a/paimon-python/pypaimon/management/permission_management.py
b/paimon-python/pypaimon/management/permission_management.py
new file mode 100644
index 0000000000..20e6236aac
--- /dev/null
+++ b/paimon-python/pypaimon/management/permission_management.py
@@ -0,0 +1,38 @@
+# 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.
+from abc import ABC, abstractmethod
+
+from pypaimon.api.api_response import PagedList
+from pypaimon.management.list_permissions_request import \
+ ListPermissionsRequest
+from pypaimon.management.permission_assignment import PermissionAssignment
+from pypaimon.management.permission_resource import PermissionResource
+
+
+class PermissionManagement(ABC):
+
+ @abstractmethod
+ def list_permissions(self, request: ListPermissionsRequest) ->
PagedList[PermissionAssignment]:
+ pass
+
+ @abstractmethod
+ def grant_permission(self, assignment: PermissionAssignment) -> None:
+ pass
+
+ @abstractmethod
+ def revoke_permission(self, resource: PermissionResource, access: str,
principal: str) -> None:
+ pass
diff --git a/paimon-python/pypaimon/management/permission_resource.py
b/paimon-python/pypaimon/management/permission_resource.py
new file mode 100644
index 0000000000..57ad0d8cac
--- /dev/null
+++ b/paimon-python/pypaimon/management/permission_resource.py
@@ -0,0 +1,120 @@
+# 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.
+from typing import Any, Dict, Optional, Union
+
+from pypaimon.management.java_string import is_blank
+from pypaimon.management.resource_type import ResourceType
+
+
+class PermissionResource:
+ FIELD_TYPE = "type"
+ FIELD_DATABASE = "database"
+ FIELD_TABLE = "table"
+ FIELD_FUNCTION = "function"
+ FIELD_VIEW = "view"
+
+ def __init__(self,
+ resource_type: Union[ResourceType, str],
+ database: Optional[str] = None,
+ table: Optional[str] = None,
+ function: Optional[str] = None,
+ view: Optional[str] = None):
+ if not isinstance(resource_type, ResourceType):
+ resource_type = ResourceType.from_string(resource_type)
+ if resource_type is None:
+ raise ValueError("resource type cannot be null")
+ _validate(resource_type, database, table, function, view)
+ self._type = resource_type
+ self._database = _blank_to_none(database)
+ self._table = _blank_to_none(table)
+ self._function = _blank_to_none(function)
+ self._view = _blank_to_none(view)
+
+ def get_type(self) -> ResourceType:
+ return self._type
+
+ def get_database(self) -> Optional[str]:
+ return self._database
+
+ def get_table(self) -> Optional[str]:
+ return self._table
+
+ def get_function(self) -> Optional[str]:
+ return self._function
+
+ def get_view(self) -> Optional[str]:
+ return self._view
+
+ def to_dict(self) -> Dict[str, Any]:
+ result = {self.FIELD_TYPE: self._type.name}
+ for name, value in ((self.FIELD_DATABASE, self._database),
+ (self.FIELD_TABLE, self._table),
+ (self.FIELD_FUNCTION, self._function),
+ (self.FIELD_VIEW, self._view)):
+ if value is not None:
+ result[name] = value
+ return result
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "PermissionResource":
+ return cls(data.get(cls.FIELD_TYPE), data.get(cls.FIELD_DATABASE),
+ data.get(cls.FIELD_TABLE), data.get(cls.FIELD_FUNCTION),
+ data.get(cls.FIELD_VIEW))
+
+ def __eq__(self, other):
+ if not isinstance(other, PermissionResource):
+ return False
+ return self._key() == other._key()
+
+ def __hash__(self):
+ return hash(self._key())
+
+ def _key(self):
+ return self._type, self._database, self._table, self._function,
self._view
+
+
+def _validate(resource_type, database, table, function, view):
+ name = resource_type.name
+ if resource_type in (ResourceType.CATALOG, ResourceType.CATALOG_ALL):
+ _check(is_blank(database) and is_blank(table) and is_blank(function)
and is_blank(view),
+ "{} resource cannot contain object identifiers.".format(name))
+ elif resource_type in (ResourceType.DATABASE, ResourceType.DATABASE_ALL):
+ _check(not is_blank(database), "database is required for {}
resource.".format(name))
+ _check(is_blank(table) and is_blank(function) and is_blank(view),
+ "{} resource cannot contain table, function, or
view.".format(name))
+ elif resource_type in (ResourceType.TABLE, ResourceType.COLUMN):
+ _check(not is_blank(database), "database is required for {}
resource.".format(name))
+ _check(not is_blank(table), "table is required for {}
resource.".format(name))
+ _check(is_blank(function) and is_blank(view),
+ "{} resource cannot contain function or view.".format(name))
+ elif resource_type == ResourceType.FUNCTION:
+ _check(not is_blank(database), "database is required for FUNCTION
resource.")
+ _check(not is_blank(function), "function is required for FUNCTION
resource.")
+ _check(is_blank(table) and is_blank(view), "FUNCTION resource cannot
contain table or view.")
+ else:
+ _check(not is_blank(database), "database is required for VIEW
resource.")
+ _check(not is_blank(view), "view is required for VIEW resource.")
+ _check(is_blank(table) and is_blank(function), "VIEW resource cannot
contain table or function.")
+
+
+def _check(condition: bool, message: str):
+ if not condition:
+ raise ValueError(message)
+
+
+def _blank_to_none(value: Optional[str]) -> Optional[str]:
+ return None if is_blank(value) else value
diff --git a/paimon-python/pypaimon/management/resource_type.py
b/paimon-python/pypaimon/management/resource_type.py
new file mode 100644
index 0000000000..148ca755ec
--- /dev/null
+++ b/paimon-python/pypaimon/management/resource_type.py
@@ -0,0 +1,38 @@
+# 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.
+from enum import Enum
+from typing import Optional
+
+
+class ResourceType(Enum):
+ CATALOG = "CATALOG"
+ CATALOG_ALL = "CATALOG_ALL"
+ DATABASE = "DATABASE"
+ DATABASE_ALL = "DATABASE_ALL"
+ TABLE = "TABLE"
+ COLUMN = "COLUMN"
+ VIEW = "VIEW"
+ FUNCTION = "FUNCTION"
+
+ @staticmethod
+ def from_string(value: Optional[str]) -> Optional["ResourceType"]:
+ if value is None:
+ return None
+ name = value.upper()
+ if name not in ResourceType.__members__:
+ raise ValueError("No enum constant ResourceType.{}".format(name))
+ return ResourceType[name]
diff --git a/paimon-python/pypaimon/tests/permission_management_json_test.py
b/paimon-python/pypaimon/tests/permission_management_json_test.py
new file mode 100644
index 0000000000..01bb606efc
--- /dev/null
+++ b/paimon-python/pypaimon/tests/permission_management_json_test.py
@@ -0,0 +1,303 @@
+# 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 json
+import unittest
+
+from pypaimon.api.api_request import (GrantPermissionRequest,
+ RevokePermissionRequest)
+from pypaimon.api.api_response import ListPermissionsResponse
+from pypaimon.common.json_util import JSON
+from pypaimon.management.list_permissions_request import \
+ ListPermissionsRequest
+from pypaimon.management.permission_access import PermissionAccess
+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
+
+ASSIGNMENT_JSON = (
+ '{"resource":{"type":"TABLE","database":"sales",'
+ '"table":"orders"},"access":"SELECT",'
+ '"principal":"analyst",'
+ '"expireTime":"2027-01-01T00:00:00Z"}')
+
+COLUMN_ASSIGNMENT_JSON = (
+ '{"resource":{"type":"COLUMN","database":"sales",'
+ '"table":"orders"},"access":"SELECT",'
+ '"principal":"analyst","columns":{'
+ '"columnNames":["id","region"]}}')
+
+
+def catalog_resource():
+ return PermissionResource(ResourceType.CATALOG, None, None, None, None)
+
+
+def catalog_all_resource():
+ return PermissionResource(ResourceType.CATALOG_ALL, None, None, None, None)
+
+
+def database_resource():
+ return PermissionResource(ResourceType.DATABASE, "sales", None, None, None)
+
+
+def database_all_resource():
+ return PermissionResource(ResourceType.DATABASE_ALL, "sales", None, None,
None)
+
+
+def table_resource():
+ return PermissionResource(ResourceType.TABLE, "sales", "orders", None,
None)
+
+
+def column_resource():
+ return PermissionResource(ResourceType.COLUMN, "sales", "orders", None,
None)
+
+
+def function_resource():
+ return PermissionResource(ResourceType.FUNCTION, "sales", None,
"calculate_tax", None)
+
+
+class PermissionManagementJsonTest(unittest.TestCase):
+
+ def assert_assignment(self, assignment):
+ self.assertEqual(ResourceType.TABLE,
assignment.get_resource().get_type())
+ self.assertEqual("sales", assignment.get_resource().get_database())
+ self.assertEqual("orders", assignment.get_resource().get_table())
+ self.assertEqual("SELECT", assignment.get_access())
+ self.assertEqual("analyst", assignment.get_principal())
+ self.assertEqual("2027-01-01T00:00:00Z", assignment.get_expire_time())
+
+ def test_assignment_deserializes(self):
+ self.assert_assignment(JSON.from_json(ASSIGNMENT_JSON,
PermissionAssignment))
+
+ def test_assignment_deserializes_lower_case_resource_type(self):
+ lower_case_json = ASSIGNMENT_JSON.replace('"TABLE"', '"table"')
+ self.assert_assignment(JSON.from_json(lower_case_json,
PermissionAssignment))
+
+ def test_list_response_does_not_apply_grant_validation(self):
+ precise_expiry = "2027-01-01T00:00:00.123456Z"
+ response_json = (
+ '{"permissions":['
+ + ASSIGNMENT_JSON.replace("2027-01-01T00:00:00Z", precise_expiry)
+ + ']}')
+
+ response = JSON.from_json(response_json, ListPermissionsResponse)
+
+ self.assertEqual(precise_expiry,
response.get_permissions()[0].get_expire_time())
+ with self.assertRaisesRegex(ValueError, "millisecond"):
+ JSON.from_json(
+ ASSIGNMENT_JSON.replace("2027-01-01T00:00:00Z",
precise_expiry),
+ GrantPermissionRequest)
+
+ def test_listed_expire_time_must_be_a_string(self):
+ with self.assertRaisesRegex(TypeError, "expireTime must be a string"):
+ JSON.from_json(
+ ASSIGNMENT_JSON.replace('"2027-01-01T00:00:00Z"',
"1798761600000"),
+ PermissionAssignment)
+
+ def test_column_assignment_round_trips(self):
+ assignment = JSON.from_json(COLUMN_ASSIGNMENT_JSON,
PermissionAssignment)
+
+ self.assertEqual(ResourceType.COLUMN,
assignment.get_resource().get_type())
+ self.assertEqual("SELECT", assignment.get_access())
+ self.assertEqual(("id", "region"),
assignment.get_columns().get_column_names())
+ self.assertIsNone(assignment.get_columns().get_excluded_column_names())
+
+ wire = json.loads(JSON.to_json(assignment))
+ self.assertEqual({"columnNames": ["id", "region"]}, wire["columns"])
+
+ def test_grant_and_revoke_use_privilege_only_wire_shapes(self):
+ assignment = JSON.from_json(ASSIGNMENT_JSON, PermissionAssignment)
+ grant = json.loads(JSON.to_json(GrantPermissionRequest(assignment)))
+
+ self.assertEqual("SELECT", grant["access"])
+ for absent in ("columns", "policy", "grantOption"):
+ self.assertNotIn(absent, grant)
+
+ revoke = json.loads(JSON.to_json(RevokePermissionRequest(
+ assignment.get_resource(), assignment.get_access(),
assignment.get_principal())))
+ self.assertEqual("SELECT", revoke["access"])
+ for absent in ("columns", "policy", "policyType", "grantOption",
"expireTime"):
+ self.assertNotIn(absent, revoke)
+
+ def test_permission_request_without_expiry(self):
+ permission_json = (
+ '{"resource":{"type":"TABLE","database":"sales",'
+ '"table":"orders"},"access":"select",'
+ '"principal":"analyst"}')
+
+ self.assertIsNone(
+ JSON.from_json(permission_json,
GrantPermissionRequest).get_expire_time())
+ self.assertEqual(
+ "SELECT", JSON.from_json(permission_json,
RevokePermissionRequest).get_access())
+
+ def test_access_and_permission_validation(self):
+ self.assertEqual("CREATEDATABASE", PermissionAssignment(
+ catalog_resource(), "createdatabase", "analyst").get_access())
+ self.assertEqual("CREATEVIEW", PermissionAssignment(
+ database_resource(), "createview", "analyst").get_access())
+ self.assertEqual("SELECT", PermissionAssignment(
+ function_resource(), "select", "analyst").get_access())
+ expected_built_ins = {
+ ResourceType.CATALOG: {"ALL", "ALTER", "DROP", "GRANT",
"CREATEDATABASE"},
+ ResourceType.CATALOG_ALL: {
+ "ALL", "DESCRIBE", "ALTER", "DROP", "GRANT", "CREATETABLE",
"CREATEVIEW",
+ "CREATEFUNCTION", "LIST", "SELECT", "UPDATE"},
+ ResourceType.DATABASE: {
+ "ALL", "DESCRIBE", "ALTER", "DROP", "GRANT", "CREATETABLE",
"CREATEVIEW",
+ "CREATEFUNCTION", "LIST"},
+ ResourceType.DATABASE_ALL: {"ALL", "ALTER", "DROP", "SELECT",
"UPDATE", "GRANT"},
+ ResourceType.TABLE: {"ALL", "ALTER", "DROP", "SELECT", "UPDATE",
"GRANT"},
+ ResourceType.VIEW: {"ALL", "ALTER", "DROP", "SELECT", "GRANT"},
+ ResourceType.FUNCTION: {"ALL", "ALTER", "DROP", "SELECT", "GRANT"},
+ ResourceType.COLUMN: {"SELECT"},
+ }
+ for resource_type, accesses in expected_built_ins.items():
+ self.assertEqual(accesses,
set(PermissionAccess.built_ins(resource_type)))
+
+ included = PermissionColumns(["id", "region"], None)
+ self.assertEqual(included, PermissionAssignment(
+ column_resource(), "select", "analyst", included).get_columns())
+
+ for resource, access, message in (
+ (catalog_resource(), "SELECT", "not valid for CATALOG"),
+ (database_resource(), "SELECT", "not valid for DATABASE"),
+ (catalog_all_resource(), "CREATEDATABASE", "not valid for
CATALOG_ALL"),
+ (database_all_resource(), "LIST", "not valid for
DATABASE_ALL"),
+ (table_resource(), "CREATEVIEW", "not valid for TABLE"),
+ (function_resource(), "UPDATE", "not valid for FUNCTION")):
+ with self.assertRaisesRegex(ValueError, message):
+ PermissionAssignment(resource, access, "analyst")
+ with self.assertRaisesRegex(ValueError, "not valid for COLUMN"):
+ PermissionAssignment(column_resource(), "UPDATE", "analyst",
included)
+ with self.assertRaisesRegex(ValueError, "columns is required"):
+ PermissionAssignment(column_resource(), "SELECT", "analyst")
+ with self.assertRaisesRegex(ValueError, "only valid for COLUMN"):
+ PermissionAssignment(table_resource(), "SELECT", "analyst",
included)
+ with self.assertRaisesRegex(ValueError, "exactly one"):
+ PermissionColumns(["id"], ["region"])
+ with self.assertRaisesRegex(ValueError, "cannot be empty"):
+ PermissionColumns([], None)
+ with self.assertRaisesRegex(ValueError, "duplicate"):
+ PermissionColumns(["id", "id"], None)
+ with self.assertRaisesRegex(ValueError, "empty"):
+ PermissionColumns([" "], None)
+ for access in ("USE_CATALOG", "CREATE_DATABASE", "USE_DATABASE",
"CREATE_TABLE",
+ "CREATE_VIEW", "CREATE_FUNCTION", "INSERT", "DELETE",
"EXECUTE",
+ "MANAGE_PERMISSIONS", "vendor.example/read_sensitive"):
+ with self.assertRaisesRegex(ValueError, "Unknown access"):
+ PermissionAssignment(table_resource(), access, "analyst")
+ with self.assertRaisesRegex(ValueError, "32"):
+ PermissionAssignment(table_resource(), "A" *
(PermissionAccess.MAX_LENGTH + 1), "analyst")
+ with self.assertRaisesRegex(ValueError, "after canonicalization"):
+ PermissionAssignment(table_resource(), "a/" + "ß" * 16, "analyst")
+ with self.assertRaisesRegex(ValueError, "128"):
+ PermissionAssignment(
+ table_resource(), "SELECT", "p" *
(PermissionAssignment.MAX_PRINCIPAL_LENGTH + 1))
+ with self.assertRaisesRegex(ValueError, "millisecond"):
+ PermissionAssignment(
+ table_resource(), "SELECT", "analyst",
expire_time="2027-01-01T00:00:00.000001Z")
+
+ def test_a_string_is_not_a_column_list(self):
+ with self.assertRaisesRegex(TypeError, "not a string"):
+ PermissionColumns(None, "email")
+
+ def test_permission_columns_defensively_copies_its_range(self):
+ source = ["id", "region"]
+ columns = PermissionColumns(source, None)
+
+ source.clear()
+ self.assertEqual(("id", "region"), columns.get_column_names())
+ with self.assertRaises(AttributeError):
+ columns.get_column_names().append("email")
+
+ def
test_permission_list_requires_exact_resource_and_bounds_page_size(self):
+ with self.assertRaisesRegex(ValueError, "exact target"):
+ ListPermissionsRequest(
+ ResourceType.TABLE, "sales", None, None, None, None, None,
None, 25)
+ with self.assertRaisesRegex(ValueError, "at most 1000"):
+ ListPermissionsRequest(
+ ResourceType.CATALOG, None, None, None, None, None, None,
None, 1001)
+
+ database_access = ListPermissionsRequest(
+ ResourceType.DATABASE, "sales", None, None, None, None,
"createview", None, 25)
+ self.assertEqual("CREATEVIEW", database_access.get_access())
+ with self.assertRaisesRegex(ValueError, "not valid for DATABASE"):
+ ListPermissionsRequest(
+ ResourceType.DATABASE, "sales", None, None, None, None,
"SELECT", None, 25)
+
+ self.assertEqual(" \t", database_access.with_page_token("
\t").get_page_token())
+
+ def test_resource_canonicalizes_blank_irrelevant_locators(self):
+ catalog = PermissionResource(ResourceType.CATALOG, "", " ", None, None)
+ catalog_all = PermissionResource(ResourceType.CATALOG_ALL, "", " ",
None, None)
+
+ self.assertEqual(catalog_resource(), catalog)
+ self.assertEqual({"type": "CATALOG"},
json.loads(JSON.to_json(catalog)))
+ self.assertEqual(catalog_all_resource(), catalog_all)
+ self.assertEqual({"type": "CATALOG_ALL"},
json.loads(JSON.to_json(catalog_all)))
+ self.assertEqual(
+ {"type": "DATABASE_ALL", "database": "sales"},
+ json.loads(JSON.to_json(database_all_resource())))
+
+ def test_blankness_follows_java_string_trim(self):
+ self.assertEqual("\u00a0", PermissionAssignment(
+ table_resource(), "SELECT", "\u00a0").get_principal())
+ with self.assertRaisesRegex(ValueError, "principal cannot be empty"):
+ PermissionAssignment(table_resource(), "SELECT", "\x00")
+ with self.assertRaisesRegex(ValueError, "access cannot be empty"):
+ PermissionAssignment(table_resource(), " \t", "analyst")
+ with self.assertRaisesRegex(ValueError, "Unknown access ' SELECT'"):
+ PermissionAssignment(table_resource(), " SELECT", "analyst")
+ self.assertEqual(" orders ", PermissionResource(
+ ResourceType.TABLE, "sales", " orders ", None, None).get_table())
+
+ def test_lengths_count_utf16_code_units(self):
+ emoji = "\U0001F600"
+ PermissionAssignment(
+ table_resource(), "SELECT", emoji *
(PermissionAssignment.MAX_PRINCIPAL_LENGTH // 2))
+ with self.assertRaisesRegex(ValueError, "128"):
+ PermissionAssignment(
+ table_resource(), "SELECT",
+ emoji * (PermissionAssignment.MAX_PRINCIPAL_LENGTH // 2 + 1))
+ with self.assertRaisesRegex(ValueError, "at most 32 characters\\.$"):
+ PermissionAssignment(table_resource(), emoji * 17, "analyst")
+
+ def test_expire_time_follows_instant_parse(self):
+ for accepted in ("2027-01-01T00:00:00Z", "2027-01-01T00:00:00.123Z",
+ "2027-01-01T00:00:00.123000000Z",
"2027-01-01t00:00:00z",
+ "2027-01-01T24:00:00Z", "2016-12-31T23:59:60Z",
+ "0000-01-01T00:00:00Z", "9999-12-31T24:00:00Z",
"+10000-01-01T00:00:00Z",
+ "-0001-01-01T00:00:00Z",
"+1000000000-12-31T23:59:59Z",
+ "-1000000000-01-01T00:00:00Z",
"2000-02-29T00:00:00Z"):
+ PermissionAssignment(table_resource(), "SELECT", "analyst",
expire_time=accepted)
+ for rejected in ("tomorrow", "2027-01-01T00:00:00+08:00",
"2027-01-01T00:00Z",
+ "2027-02-30T00:00:00Z", "2027-01-01 00:00:00Z",
+ "2027-01-01T00:00:00.1234567890Z",
+ "\uff12\uff10\uff12\uff17-01-01T00:00:00Z",
+ "10000-01-01T00:00:00Z", "+2027-01-01T00:00:00Z",
"-0000-01-01T00:00:00Z",
+ "+1000000001-01-01T00:00:00Z",
"-1000000001-12-31T23:59:59Z",
+ "1900-02-29T00:00:00Z",
"+1000000000-12-31T24:00:00Z"):
+ with self.assertRaisesRegex(ValueError, "ISO-8601 UTC instant"):
+ PermissionAssignment(table_resource(), "SELECT", "analyst",
expire_time=rejected)
+ with self.assertRaisesRegex(ValueError, "millisecond"):
+ PermissionAssignment(
+ table_resource(), "SELECT", "analyst",
expire_time="2027-01-01T00:00:00.1234Z")
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
b/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
index 53368b8f54..fcbb1347ad 100644
--- a/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
+++ b/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
@@ -92,6 +92,19 @@ class DLFSignerTest(unittest.TestCase):
# Test identifier
self.assertEqual("openapi", signer.identifier())
+ def test_openapi_signature_matches_java_for_encoded_prefixes(self):
+ signer = DLFOpenApiSigner()
+ token = DLFToken("AccessKeyId", "AccessKeySecret", "security-token",
None)
+ now = datetime(2025, 4, 16, 3, 44, 46, tzinfo=timezone.utc)
+ host = "dlfnext.cn-beijing.aliyuncs.com"
+ for prefix, java in (("catalog id", "acs
AccessKeyId:KQN7BVdojufy1Y9Bm8cunx4NZ1E="),
+ ("a+b", "acs
AccessKeyId:ttllEDhpL4JgTDVm422r8qp9MZ4="),
+ ("catalog/id", "acs
AccessKeyId:YlBMkloyu6zbfnjd9qRYELW3+cc=")):
+ headers = signer.sign_headers(None, now, token.security_token,
host)
+ headers["x-acs-signature-nonce"] = "fixed-nonce"
+ parameter = RESTAuthParameter("GET",
ResourcePaths(prefix).permissions(), None, {})
+ self.assertEqual(java, signer.authorization(parameter, token,
host, headers), prefix)
+
def test_openapi_date_format_with_chinese_locale(self):
"""Date header must stay English RFC 1123 under zh_CN locale."""
original = locale.setlocale(locale.LC_TIME, None)
diff --git
a/paimon-python/pypaimon/tests/rest/rest_permission_management_test.py
b/paimon-python/pypaimon/tests/rest/rest_permission_management_test.py
new file mode 100644
index 0000000000..a71342a839
--- /dev/null
+++ b/paimon-python/pypaimon/tests/rest/rest_permission_management_test.py
@@ -0,0 +1,220 @@
+# 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 json
+import threading
+import unittest
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import parse_qsl, urlsplit
+
+from pypaimon.api.resource_paths import ResourcePaths
+from pypaimon.api.rest_api import RESTApi
+from pypaimon.api.rest_exception import ForbiddenException
+from pypaimon.api.rest_permission_management import RESTPermissionManagement
+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
+
+BASE_PATH = "/v1/catalog%2Fid/permissions"
+
+LIST_RESPONSE = (
+ '{"permissions":[{"resource":{"type":"TABLE",'
+ '"database":"sales","table":"orders"},'
+ '"access":"SELECT",'
+ '"principal":"analyst"}],'
+ '"nextPageToken":"next"}')
+
+
+def assignment(principal):
+ return PermissionAssignment(
+ PermissionResource(ResourceType.TABLE, "sales", "orders", None, None),
+ "SELECT",
+ principal)
+
+
+class RESTPermissionManagementTest(unittest.TestCase):
+
+ def setUp(self):
+ recorded = self.recorded = {"revoke_calls": 0, "base": BASE_PATH,
"received": []}
+
+ class Handler(BaseHTTPRequestHandler):
+
+ def log_message(self, *args):
+ pass
+
+ def respond(self, code, body=None):
+ data = body.encode("utf-8") if body else b""
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def do_GET(self):
+ recorded["authorization"] = self.headers.get("Authorization")
+ url = urlsplit(self.path)
+ recorded["received"].append(url.path)
+ if url.path == recorded["base"]:
+ recorded["list_query"] = dict(parse_qsl(url.query,
keep_blank_values=True))
+ self.respond(200, LIST_RESPONSE)
+ else:
+ self.respond(404, '{"message":"missing","code":404}')
+
+ def do_POST(self):
+ recorded["authorization"] = self.headers.get("Authorization")
+ body = self.rfile.read(int(self.headers.get("Content-Length",
0))).decode("utf-8")
+ path = urlsplit(self.path).path
+ recorded["received"].append(path)
+ if path == recorded["base"] + "/grant":
+ recorded["grant"] = body
+ if "denied" in body:
+ self.respond(403, '{"message":"forbidden","code":403}')
+ else:
+ self.respond(200)
+ elif path == recorded["base"] + "/revoke":
+ recorded["revoke"] = body
+ recorded["revoke_calls"] += 1
+ self.respond(200)
+ else:
+ self.respond(404, '{"message":"missing","code":404}')
+
+ self.server = HTTPServer(("127.0.0.1", 0), Handler)
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
+ self.management, _ = self.management_for("catalog/id")
+
+ def tearDown(self):
+ self.server.shutdown()
+ self.server.server_close()
+
+ def management_for(self, prefix):
+ api = RESTApi({
+ "uri": "http://127.0.0.1:{}".format(self.server.server_port),
+ "token.provider": "bear",
+ "token": "secret",
+ "prefix": prefix,
+ }, False)
+ signed = []
+ sign = api.rest_auth_function
+
+ def record(parameter):
+ signed.append(parameter.path)
+ return sign(parameter)
+
+ api.rest_auth_function = record
+ return RESTPermissionManagement(api), signed
+
+ def test_the_signed_path_is_the_path_sent(self):
+ granted = assignment("analyst")
+ for prefix, base in (("a~b*c", "/v1/a~b*c/permissions"),
+ ("catalog/id", BASE_PATH),
+ ("catalog id", "/v1/catalog+id/permissions")):
+ self.recorded["base"] = base
+ self.recorded["received"] = []
+ management, signed = self.management_for(prefix)
+
management.list_permissions(ListPermissionsRequest(ResourceType.CATALOG))
+ management.grant_permission(granted)
+ management.revoke_permission(
+ granted.get_resource(), granted.get_access(),
granted.get_principal())
+ self.assertEqual([base, base + "/grant", base + "/revoke"], signed)
+ self.assertEqual(signed, self.recorded["received"])
+
+ def test_permission_paths_encode_the_prefix_as_one_segment(self):
+ paths = ResourcePaths("catalog/id")
+ self.assertEqual("/v1/catalog%2Fid/permissions", paths.permissions())
+ self.assertEqual("/v1/catalog%2Fid/permissions/grant",
paths.grant_permission())
+ self.assertEqual("/v1/catalog%2Fid/permissions/revoke",
paths.revoke_permission())
+ self.assertEqual("/v1/catalog+id/permissions", ResourcePaths("catalog
id").permissions())
+ self.assertEqual("/v1/a~b*c/permissions",
ResourcePaths("a~b*c").permissions())
+
+ def test_list_uses_prefix_and_complete_filters(self):
+ page = self.management.list_permissions(ListPermissionsRequest(
+ ResourceType.TABLE, "sales", "orders", None, None, "analyst",
None, "start", 25))
+
+ self.assertEqual(1, len(page.elements))
+ self.assertEqual("analyst", page.elements[0].get_principal())
+ self.assertEqual("next", page.next_page_token)
+ self.assertEqual(
+ {"principal": "analyst", "resourceType": "TABLE", "database":
"sales",
+ "table": "orders", "maxResults": "25", "pageToken": "start"},
+ self.recorded["list_query"])
+ self.assertEqual("Bearer secret", self.recorded["authorization"])
+
+ def test_list_sends_a_whitespace_page_token_verbatim(self):
+ request = ListPermissionsRequest(ResourceType.CATALOG, page_token="
\t")
+ self.management.list_permissions(request)
+ self.assertEqual(
+ {"resourceType": "CATALOG", "pageToken": " \t"},
self.recorded["list_query"])
+
+ self.management.list_permissions(request.with_page_token(""))
+ self.assertEqual({"resourceType": "CATALOG"},
self.recorded["list_query"])
+
+ def test_grant_and_revoke_use_structured_wire_shapes(self):
+ granted = assignment("analyst")
+ self.management.grant_permission(granted)
+ self.management.revoke_permission(
+ granted.get_resource(), "select", granted.get_principal())
+
+ grant = json.loads(self.recorded["grant"])
+ self.assertEqual(
+ {"type": "TABLE", "database": "sales", "table": "orders"},
grant["resource"])
+ self.assertEqual("analyst", grant["principal"])
+ for absent in ("columns", "policy", "grantOption", "catalog"):
+ self.assertNotIn(absent, grant)
+
+ revoke = json.loads(self.recorded["revoke"])
+ self.assertEqual(
+ {"type": "TABLE", "database": "sales", "table": "orders"},
revoke["resource"])
+ self.assertEqual("SELECT", revoke["access"])
+ self.assertEqual("analyst", revoke["principal"])
+ for absent in ("expireTime", "grantOption"):
+ self.assertNotIn(absent, revoke)
+
+ def test_forbidden_grant_preserves_rest_error_translation(self):
+ with self.assertRaisesRegex(ForbiddenException, "forbidden"):
+ self.management.grant_permission(assignment("denied"))
+
+ def test_column_grant_carries_range_but_revoke_uses_only_identity(self):
+ granted = PermissionAssignment(
+ PermissionResource(ResourceType.COLUMN, "sales", "orders", None,
None),
+ "SELECT",
+ "analyst",
+ PermissionColumns(["id", "region"], None))
+
+ self.management.grant_permission(granted)
+ grant = json.loads(self.recorded["grant"])
+ self.assertEqual(["id", "region"], grant["columns"]["columnNames"])
+
+ self.management.revoke_permission(
+ granted.get_resource(), granted.get_access(),
granted.get_principal())
+ revoke = json.loads(self.recorded["revoke"])
+ self.assertEqual("COLUMN", revoke["resource"]["type"])
+ self.assertNotIn("columns", revoke)
+
+ def test_repeated_revoke_is_idempotent(self):
+ revoked = assignment("missing")
+ for _ in range(2):
+ self.management.revoke_permission(
+ revoked.get_resource(), revoked.get_access(),
revoked.get_principal())
+
+ self.assertEqual(2, self.recorded["revoke_calls"])
+
+
+if __name__ == '__main__':
+ unittest.main()