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 23e3b14074 [python] Add REST policy management (#10384)
23e3b14074 is described below
commit 23e3b140748c70d13fa0f2f8c831900bb5d17fdc
Author: Jiajia Li <[email protected]>
AuthorDate: Thu Oct 8 16:16:14 2026 +0800
[python] Add REST policy management (#10384)
---
paimon-python/pypaimon/api/api_request.py | 94 ++++++++++
paimon-python/pypaimon/api/api_response.py | 34 ++++
paimon-python/pypaimon/api/resource_paths.py | 8 +
paimon-python/pypaimon/api/rest_api.py | 48 ++++-
.../pypaimon/api/rest_policy_management.py | 48 +++++
.../pypaimon/catalog/rest/rest_catalog.py | 5 +
paimon-python/pypaimon/management/column_mask.py | 49 ++++++
paimon-python/pypaimon/management/data_policy.py | 89 ++++++++++
paimon-python/pypaimon/management/java_string.py | 5 +
.../pypaimon/management/list_policies_request.py | 75 ++++++++
.../pypaimon/management/permission_resource.py | 4 +
.../pypaimon/management/policy_management.py | 55 ++++++
.../management/{java_string.py => policy_type.py} | 19 +-
paimon-python/pypaimon/management/row_filter.py | 42 +++++
.../pypaimon/tests/policy_management_json_test.py | 151 ++++++++++++++++
.../tests/rest/rest_policy_management_test.py | 193 +++++++++++++++++++++
16 files changed, 910 insertions(+), 9 deletions(-)
diff --git a/paimon-python/pypaimon/api/api_request.py
b/paimon-python/pypaimon/api/api_request.py
index c5ccf5399d..04482e9247 100644
--- a/paimon-python/pypaimon/api/api_request.py
+++ b/paimon-python/pypaimon/api/api_request.py
@@ -23,10 +23,15 @@ 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.column_mask import ColumnMask
+from pypaimon.management.data_policy import DataPolicy
+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_columns import PermissionColumns
from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.policy_type import PolicyType
+from pypaimon.management.row_filter import RowFilter
from pypaimon.schema.data_types import DataField
from pypaimon.schema.schema import Schema
from pypaimon.schema.schema_change import SchemaChange
@@ -288,3 +293,92 @@ class RevokePermissionRequest(RESTRequest):
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))
+
+
+class PolicyRequest(RESTRequest):
+ FIELD_ROW_FILTER = "rowFilter"
+ FIELD_COLUMN_MASK = "columnMask"
+ FIELD_PRINCIPAL = "principal"
+
+ def __init__(self, row_filter: Optional[RowFilter], column_mask:
Optional[ColumnMask],
+ principal: str):
+ self._row_filter = row_filter
+ self._column_mask = column_mask
+ self._principal = principal
+
+ @classmethod
+ def from_policy(cls, policy: DataPolicy) -> "PolicyRequest":
+ return cls(policy.get_row_filter(), policy.get_column_mask(),
policy.get_principal())
+
+ def policy(self, resource: PermissionResource) -> DataPolicy:
+ return DataPolicy(resource, self._row_filter, self._column_mask,
self._principal)
+
+ def get_row_filter(self) -> Optional[RowFilter]:
+ return self._row_filter
+
+ def get_column_mask(self) -> Optional[ColumnMask]:
+ return self._column_mask
+
+ def get_principal(self) -> str:
+ return self._principal
+
+ def to_dict(self) -> dict:
+ result = {}
+ if self._row_filter is not None:
+ result[self.FIELD_ROW_FILTER] = self._row_filter.to_dict()
+ if self._column_mask is not None:
+ result[self.FIELD_COLUMN_MASK] = self._column_mask.to_dict()
+ result[self.FIELD_PRINCIPAL] = self._principal
+ return result
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "PolicyRequest":
+ row_filter = data.get(cls.FIELD_ROW_FILTER)
+ column_mask = data.get(cls.FIELD_COLUMN_MASK)
+ return cls(None if row_filter is None else
RowFilter.from_dict(row_filter),
+ None if column_mask is None else
ColumnMask.from_dict(column_mask),
+ data.get(cls.FIELD_PRINCIPAL))
+
+
+class DropPolicyRequest(RESTRequest):
+ FIELD_TYPE = "type"
+ FIELD_PRINCIPAL = "principal"
+ FIELD_COLUMN = "column"
+
+ def __init__(self, policy_type: PolicyType, principal: str, column:
Optional[str] = None):
+ if policy_type is None:
+ raise ValueError("policy type cannot be null")
+ self._type = policy_type
+ self._principal = PermissionAssignment.validate_principal(principal)
+ if policy_type == PolicyType.ROW_FILTER:
+ if not is_blank(column):
+ raise ValueError("ROW_FILTER identity cannot contain a
column.")
+ self._column = None
+ else:
+ if is_blank(column):
+ raise ValueError("column is required for COLUMN_MASKING
identity.")
+ self._column = column
+
+ def get_type(self) -> PolicyType:
+ return self._type
+
+ def get_principal(self) -> str:
+ return self._principal
+
+ def get_column(self) -> Optional[str]:
+ return self._column
+
+ def to_dict(self) -> dict:
+ result = {self.FIELD_TYPE: self._type.name, self.FIELD_PRINCIPAL:
self._principal}
+ if self._column is not None:
+ result[self.FIELD_COLUMN] = self._column
+ return result
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "DropPolicyRequest":
+ policy_type = data.get(cls.FIELD_TYPE)
+ # Jackson matches enum names exactly.
+ if policy_type is not None and policy_type not in
PolicyType.__members__:
+ raise ValueError("Unknown policy type '{}'.".format(policy_type))
+ return cls(None if policy_type is None else PolicyType[policy_type],
+ data.get(cls.FIELD_PRINCIPAL), data.get(cls.FIELD_COLUMN))
diff --git a/paimon-python/pypaimon/api/api_response.py
b/paimon-python/pypaimon/api/api_response.py
index 081ec4945f..837e2ba7ff 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.data_policy import DataPolicy
from pypaimon.management.permission_assignment import PermissionAssignment
from pypaimon.schema.data_types import DataField
from pypaimon.schema.schema import Schema
@@ -54,6 +55,7 @@ class ErrorResponse(RESTResponse):
RESOURCE_TYPE_BRANCH = "BRANCH"
RESOURCE_TYPE_DEFINITION = "DEFINITION"
RESOURCE_TYPE_DIALECT = "DIALECT"
+ RESOURCE_TYPE_POLICY = "POLICY"
resource_type: Optional[str] = json_field("resourceType", default=None)
resource_name: Optional[str] = json_field("resourceName", default=None)
@@ -665,3 +667,35 @@ class
ListPermissionsResponse(PagedResponse[PermissionAssignment]):
None if permission is None else
PermissionAssignment.from_dict(permission)
for permission in permissions],
data.get(PagedResponse.FIELD_NEXT_PAGE_TOKEN))
+
+
+class ListPoliciesResponse(PagedResponse[DataPolicy]):
+ FIELD_POLICIES = "policies"
+
+ def __init__(self, policies: Optional[List[DataPolicy]], next_page_token:
Optional[str] = None):
+ self._policies = policies
+ self._next_page_token = next_page_token
+
+ def get_policies(self) -> Optional[List[DataPolicy]]:
+ return self._policies
+
+ def data(self) -> Optional[List[DataPolicy]]:
+ return self._policies
+
+ def get_next_page_token(self) -> Optional[str]:
+ return self._next_page_token
+
+ def to_dict(self) -> dict:
+ result = {self.FIELD_POLICIES: None if self._policies is None else [
+ None if policy is None else policy.to_dict() for policy in
self._policies]}
+ 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) -> "ListPoliciesResponse":
+ policies = data.get(cls.FIELD_POLICIES)
+ return cls(
+ None if policies is None else [
+ None if policy is None else DataPolicy.from_dict(policy) for
policy in policies],
+ data.get(PagedResponse.FIELD_NEXT_PAGE_TOKEN))
diff --git a/paimon-python/pypaimon/api/resource_paths.py
b/paimon-python/pypaimon/api/resource_paths.py
index 94f61948a3..45f82364f6 100644
--- a/paimon-python/pypaimon/api/resource_paths.py
+++ b/paimon-python/pypaimon/api/resource_paths.py
@@ -36,6 +36,7 @@ class ResourcePaths:
RENAME = "rename"
FORWARD = "forward"
PERMISSIONS = "permissions"
+ POLICIES = "policies"
def __init__(self, prefix: str):
# Java encodes the prefix once with URLEncoder, so it stays one path
segment. '~' stays raw:
@@ -154,3 +155,10 @@ class ResourcePaths:
def revoke_permission(self) -> str:
return "{}/revoke".format(self.permissions())
+
+ def policies(self, resource) -> str:
+ resource.validate_policy_attachment()
+ return "{}/{}".format(self.table(resource.get_database(),
resource.get_table()), self.POLICIES)
+
+ def drop_policy(self, resource) -> str:
+ return "{}/drop".format(self.policies(resource))
diff --git a/paimon-python/pypaimon/api/rest_api.py
b/paimon-python/pypaimon/api/rest_api.py
index b6ee8f4144..543c0858b3 100755
--- a/paimon-python/pypaimon/api/rest_api.py
+++ b/paimon-python/pypaimon/api/rest_api.py
@@ -25,12 +25,13 @@ from pypaimon.api.api_request import (AlterDatabaseRequest,
AlterFunctionRequest
CreateBranchRequest,
CreateDatabaseRequest,
CreateFunctionRequest,
CreatePartitionsRequest,
CreateTableRequest, CreateTagRequest,
- ForwardBranchRequest,
- GrantPermissionRequest,
+ DropPolicyRequest, ForwardBranchRequest,
+ GrantPermissionRequest, PolicyRequest,
RenameBranchRequest, RenameTableRequest,
RevokePermissionRequest,
RollbackTableRequest)
from pypaimon.api.api_response import (CommitTableResponse, ConfigResponse,
+ ErrorResponse,
GetDatabaseResponse,
GetFunctionResponse,
GetTableResponse,
GetTableTokenResponse, GetTagResponse,
@@ -40,6 +41,7 @@ from pypaimon.api.api_response import (CommitTableResponse,
ConfigResponse,
ListFunctionsGloballyResponse,
ListFunctionsResponse,
ListPermissionsResponse,
+ ListPoliciesResponse,
CreatePartitionsResponse,
ListPartitionsResponse,
ListTablesResponse, ListTagsResponse,
@@ -51,16 +53,20 @@ from pypaimon.api.api_response import (CommitTableResponse,
ConfigResponse,
from pypaimon.api.auth import AuthProviderFactory, RESTAuthFunction
from pypaimon.api.client import HttpClient
from pypaimon.api.resource_paths import ResourcePaths
+from pypaimon.api.rest_exception import NoSuchResourceException
from pypaimon.api.rest_util import RESTUtil
from pypaimon.api.typedef import T
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.data_policy import DataPolicy
from pypaimon.management.list_permissions_request import \
ListPermissionsRequest
+from pypaimon.management.list_policies_request import ListPoliciesRequest
from pypaimon.management.permission_assignment import PermissionAssignment
from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.policy_type import PolicyType
from pypaimon.schema.schema import Schema
from pypaimon.snapshot.snapshot import Snapshot
from pypaimon.snapshot.snapshot_commit import PartitionStatistics
@@ -770,6 +776,44 @@ class RESTApi:
self.rest_auth_function,
)
+ def list_policies(self, request: ListPoliciesRequest) ->
ListPoliciesResponse:
+ query_params = {}
+ if request.get_type() is not None:
+ RESTApi._put_query_parameter(query_params, "type",
request.get_type().name)
+ RESTApi._put_query_parameter(query_params, "principal",
request.get_principal())
+ RESTApi._put_query_parameter(query_params, "column",
request.get_column())
+ 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.policies(request.get_resource()),
+ query_params,
+ ListPoliciesResponse,
+ self.rest_auth_function,
+ )
+
+ def create_policy(self, policy: DataPolicy) -> None:
+ self.client.post(
+ self.resource_paths.policies(policy.get_resource()),
+ PolicyRequest.from_policy(policy),
+ self.rest_auth_function,
+ )
+
+ def drop_policy(self, resource: PermissionResource, policy_type:
PolicyType, principal: str,
+ column: Optional[str], ignore_if_not_exists: bool) -> None:
+ if resource is None:
+ raise ValueError("resource cannot be null")
+ resource.validate_policy_attachment()
+ try:
+ self.client.post(
+ self.resource_paths.drop_policy(resource),
+ DropPolicyRequest(policy_type, principal, column),
+ self.rest_auth_function,
+ )
+ except NoSuchResourceException as e:
+ if not ignore_if_not_exists or e.resource_type !=
ErrorResponse.RESOURCE_TYPE_POLICY:
+ raise
+
@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.
diff --git a/paimon-python/pypaimon/api/rest_policy_management.py
b/paimon-python/pypaimon/api/rest_policy_management.py
new file mode 100644
index 0000000000..8d55d2ce43
--- /dev/null
+++ b/paimon-python/pypaimon/api/rest_policy_management.py
@@ -0,0 +1,48 @@
+# 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.api.api_response import ErrorResponse, PagedList
+from pypaimon.api.rest_exception import AlreadyExistsException
+from pypaimon.management.data_policy import DataPolicy
+from pypaimon.management.list_policies_request import ListPoliciesRequest
+from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.policy_management import (PolicyAlreadyExistException,
+ PolicyManagement)
+from pypaimon.management.policy_type import PolicyType
+
+
+class RESTPolicyManagement(PolicyManagement):
+
+ def __init__(self, api):
+ self.api = api
+
+ def list_policies(self, request: ListPoliciesRequest) ->
PagedList[DataPolicy]:
+ response = self.api.list_policies(request)
+ return PagedList(response.get_policies(),
response.get_next_page_token())
+
+ def create_policy(self, policy: DataPolicy) -> None:
+ try:
+ self.api.create_policy(policy)
+ except AlreadyExistsException as e:
+ if e.resource_type == ErrorResponse.RESOURCE_TYPE_POLICY:
+ raise PolicyAlreadyExistException(policy) from e
+ raise
+
+ def drop_policy(self, resource: PermissionResource, policy_type:
PolicyType, principal: str,
+ column: Optional[str], ignore_if_not_exists: bool) -> None:
+ self.api.drop_policy(resource, policy_type, principal, column,
ignore_if_not_exists)
diff --git a/paimon-python/pypaimon/catalog/rest/rest_catalog.py
b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
index 12fc512c29..ac33e55c30 100644
--- a/paimon-python/pypaimon/catalog/rest/rest_catalog.py
+++ b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
@@ -20,6 +20,7 @@ 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.api.rest_policy_management import RESTPolicyManagement
from pypaimon.catalog.catalog_exception import IllegalArgumentError,
IllegalStateError
from pypaimon.api.rest_exception import (NoSuchResourceException,
AlreadyExistsException,
ForbiddenException,
BadRequestException,
@@ -48,6 +49,7 @@ 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.management.policy_management import PolicyManagement
from pypaimon.schema.schema import Schema
from pypaimon.schema.schema_change import SchemaChange
from pypaimon.schema.table_schema import TableSchema
@@ -97,6 +99,9 @@ class RESTCatalog(Catalog):
def permission_management(self) -> PermissionManagement:
return RESTPermissionManagement(self.rest_api)
+ def policy_management(self) -> PolicyManagement:
+ return RESTPolicyManagement(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/column_mask.py
b/paimon-python/pypaimon/management/column_mask.py
new file mode 100644
index 0000000000..30eb2e48b8
--- /dev/null
+++ b/paimon-python/pypaimon/management/column_mask.py
@@ -0,0 +1,49 @@
+# 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
+
+from pypaimon.management.java_string import is_blank, utf8_length
+
+
+class ColumnMask:
+ MAX_TRANSFORM_BYTES = 60 * 1024
+
+ FIELD_ON_COLUMN = "onColumn"
+ FIELD_TRANSFORM = "transform"
+
+ def __init__(self, on_column: str, transform: str):
+ if is_blank(on_column):
+ raise ValueError("onColumn cannot be empty.")
+ if is_blank(transform):
+ raise ValueError("transform cannot be empty.")
+ if utf8_length(transform) > self.MAX_TRANSFORM_BYTES:
+ raise ValueError("transform must not exceed {} UTF-8
bytes.".format(self.MAX_TRANSFORM_BYTES))
+ self._on_column = on_column
+ self._transform = transform
+
+ def get_on_column(self) -> str:
+ return self._on_column
+
+ def get_transform(self) -> str:
+ return self._transform
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {self.FIELD_ON_COLUMN: self._on_column, self.FIELD_TRANSFORM:
self._transform}
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "ColumnMask":
+ return cls(data.get(cls.FIELD_ON_COLUMN),
data.get(cls.FIELD_TRANSFORM))
diff --git a/paimon-python/pypaimon/management/data_policy.py
b/paimon-python/pypaimon/management/data_policy.py
new file mode 100644
index 0000000000..a98459bfc2
--- /dev/null
+++ b/paimon-python/pypaimon/management/data_policy.py
@@ -0,0 +1,89 @@
+# 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
+
+from pypaimon.management.column_mask import ColumnMask
+from pypaimon.management.permission_assignment import PermissionAssignment
+from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.policy_type import PolicyType
+from pypaimon.management.row_filter import RowFilter
+
+
+class DataPolicy:
+ FIELD_RESOURCE = "resource"
+ FIELD_ROW_FILTER = "rowFilter"
+ FIELD_COLUMN_MASK = "columnMask"
+ FIELD_PRINCIPAL = "principal"
+
+ def __init__(self,
+ resource: PermissionResource,
+ row_filter: Optional[RowFilter],
+ column_mask: Optional[ColumnMask],
+ principal: str):
+ if resource is None:
+ raise ValueError("resource cannot be null")
+ resource.validate_policy_attachment()
+ if (row_filter is None) == (column_mask is None):
+ raise ValueError("A policy must contain exactly one of rowFilter
and columnMask.")
+ self._resource = resource
+ self._row_filter = row_filter
+ self._column_mask = column_mask
+ self._principal = PermissionAssignment.validate_principal(principal)
+
+ @classmethod
+ def row_filter(cls, resource: PermissionResource, row_filter: RowFilter,
+ principal: str) -> "DataPolicy":
+ return cls(resource, row_filter, None, principal)
+
+ @classmethod
+ def column_mask(cls, resource: PermissionResource, column_mask: ColumnMask,
+ principal: str) -> "DataPolicy":
+ return cls(resource, None, column_mask, principal)
+
+ def get_resource(self) -> PermissionResource:
+ return self._resource
+
+ def get_row_filter(self) -> Optional[RowFilter]:
+ return self._row_filter
+
+ def get_column_mask(self) -> Optional[ColumnMask]:
+ return self._column_mask
+
+ def type(self) -> PolicyType:
+ return PolicyType.COLUMN_MASKING if self._row_filter is None else
PolicyType.ROW_FILTER
+
+ def get_principal(self) -> str:
+ return self._principal
+
+ def to_dict(self) -> Dict[str, Any]:
+ result = {self.FIELD_RESOURCE: self._resource.to_dict()}
+ if self._row_filter is not None:
+ result[self.FIELD_ROW_FILTER] = self._row_filter.to_dict()
+ if self._column_mask is not None:
+ result[self.FIELD_COLUMN_MASK] = self._column_mask.to_dict()
+ result[self.FIELD_PRINCIPAL] = self._principal
+ return result
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "DataPolicy":
+ resource = data.get(cls.FIELD_RESOURCE)
+ row_filter = data.get(cls.FIELD_ROW_FILTER)
+ column_mask = data.get(cls.FIELD_COLUMN_MASK)
+ return cls(None if resource is None else
PermissionResource.from_dict(resource),
+ None if row_filter is None else
RowFilter.from_dict(row_filter),
+ None if column_mask is None else
ColumnMask.from_dict(column_mask),
+ data.get(cls.FIELD_PRINCIPAL))
diff --git a/paimon-python/pypaimon/management/java_string.py
b/paimon-python/pypaimon/management/java_string.py
index ba92ab453c..f53b3981d7 100644
--- a/paimon-python/pypaimon/management/java_string.py
+++ b/paimon-python/pypaimon/management/java_string.py
@@ -25,3 +25,8 @@ def is_blank(value: Optional[str]) -> bool:
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
+
+
+def utf8_length(value: str) -> int:
+ """Java ``getBytes(UTF_8).length``: an unpaired surrogate counts as one
byte."""
+ return len(value.encode('utf-8', 'replace'))
diff --git a/paimon-python/pypaimon/management/list_policies_request.py
b/paimon-python/pypaimon/management/list_policies_request.py
new file mode 100644
index 0000000000..a9adb2a8d0
--- /dev/null
+++ b/paimon-python/pypaimon/management/list_policies_request.py
@@ -0,0 +1,75 @@
+# 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.list_permissions_request import \
+ ListPermissionsRequest
+from pypaimon.management.permission_assignment import PermissionAssignment
+from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.policy_type import PolicyType
+
+
+class ListPoliciesRequest:
+
+ def __init__(self,
+ resource: PermissionResource,
+ policy_type: Optional[PolicyType] = None,
+ principal: Optional[str] = None,
+ column: Optional[str] = None,
+ page_token: Optional[str] = None,
+ max_results: Optional[int] = None):
+ if resource is None:
+ raise ValueError("resource cannot be null")
+ resource.validate_policy_attachment()
+ 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 >
ListPermissionsRequest.MAX_PAGE_SIZE:
+ raise ValueError(
+ "maxResults must be at most
{}.".format(ListPermissionsRequest.MAX_PAGE_SIZE))
+ if not is_blank(column) and policy_type != PolicyType.COLUMN_MASKING:
+ raise ValueError("column filter requires type COLUMN_MASKING.")
+ self._resource = resource
+ self._type = policy_type
+ self._principal = None if is_blank(principal) else principal
+ self._column = None if is_blank(column) else column
+ self._page_token = page_token
+ self._max_results = max_results
+
+ def get_resource(self) -> PermissionResource:
+ return self._resource
+
+ def get_type(self) -> Optional[PolicyType]:
+ return self._type
+
+ def get_principal(self) -> Optional[str]:
+ return self._principal
+
+ def get_column(self) -> Optional[str]:
+ return self._column
+
+ def get_page_token(self) -> Optional[str]:
+ return self._page_token
+
+ def get_max_results(self) -> Optional[int]:
+ return self._max_results
+
+ def with_page_token(self, new_page_token: Optional[str]) ->
"ListPoliciesRequest":
+ return ListPoliciesRequest(self._resource, self._type,
self._principal, self._column,
+ new_page_token, self._max_results)
diff --git a/paimon-python/pypaimon/management/permission_resource.py
b/paimon-python/pypaimon/management/permission_resource.py
index 57ad0d8cac..032c4fe947 100644
--- a/paimon-python/pypaimon/management/permission_resource.py
+++ b/paimon-python/pypaimon/management/permission_resource.py
@@ -59,6 +59,10 @@ class PermissionResource:
def get_view(self) -> Optional[str]:
return self._view
+ def validate_policy_attachment(self):
+ if self._type != ResourceType.TABLE:
+ raise ValueError("Policies can currently be attached only to TABLE
resources.")
+
def to_dict(self) -> Dict[str, Any]:
result = {self.FIELD_TYPE: self._type.name}
for name, value in ((self.FIELD_DATABASE, self._database),
diff --git a/paimon-python/pypaimon/management/policy_management.py
b/paimon-python/pypaimon/management/policy_management.py
new file mode 100644
index 0000000000..377593de9e
--- /dev/null
+++ b/paimon-python/pypaimon/management/policy_management.py
@@ -0,0 +1,55 @@
+# 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 typing import Optional
+
+from pypaimon.api.api_response import PagedList
+from pypaimon.management.data_policy import DataPolicy
+from pypaimon.management.list_policies_request import ListPoliciesRequest
+from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.policy_type import PolicyType
+
+
+class PolicyManagement(ABC):
+
+ @abstractmethod
+ def list_policies(self, request: ListPoliciesRequest) ->
PagedList[DataPolicy]:
+ pass
+
+ @abstractmethod
+ def create_policy(self, policy: DataPolicy) -> None:
+ pass
+
+ @abstractmethod
+ def drop_policy(self, resource: PermissionResource, policy_type:
PolicyType, principal: str,
+ column: Optional[str], ignore_if_not_exists: bool) -> None:
+ pass
+
+
+class PolicyAlreadyExistException(Exception):
+
+ def __init__(self, policy: DataPolicy):
+ target = policy.type().name
+ if policy.get_column_mask() is not None:
+ target += "(" + policy.get_column_mask().get_on_column() + ")"
+ resource = policy.get_resource()
+ super().__init__("{} policy for principal '{}' already exists on table
'{}.{}'.".format(
+ target, policy.get_principal(), resource.get_database(),
resource.get_table()))
+ self._policy = policy
+
+ def policy(self) -> DataPolicy:
+ return self._policy
diff --git a/paimon-python/pypaimon/management/java_string.py
b/paimon-python/pypaimon/management/policy_type.py
similarity index 65%
copy from paimon-python/pypaimon/management/java_string.py
copy to paimon-python/pypaimon/management/policy_type.py
index ba92ab453c..cb3155d84b 100644
--- a/paimon-python/pypaimon/management/java_string.py
+++ b/paimon-python/pypaimon/management/policy_type.py
@@ -14,14 +14,19 @@
# 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
-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)
+class PolicyType(Enum):
+ ROW_FILTER = "ROW_FILTER"
+ COLUMN_MASKING = "COLUMN_MASKING"
-
-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
+ @staticmethod
+ def from_string(value: Optional[str]) -> Optional["PolicyType"]:
+ if value is None:
+ return None
+ name = value.upper()
+ if name not in PolicyType.__members__:
+ raise ValueError("No enum constant PolicyType.{}".format(name))
+ return PolicyType[name]
diff --git a/paimon-python/pypaimon/management/row_filter.py
b/paimon-python/pypaimon/management/row_filter.py
new file mode 100644
index 0000000000..ec43d74811
--- /dev/null
+++ b/paimon-python/pypaimon/management/row_filter.py
@@ -0,0 +1,42 @@
+# 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
+
+from pypaimon.management.java_string import is_blank, utf8_length
+
+
+class RowFilter:
+ MAX_PREDICATE_BYTES = 60 * 1024
+
+ FIELD_PREDICATE = "predicate"
+
+ def __init__(self, predicate: str):
+ if is_blank(predicate):
+ raise ValueError("predicate cannot be empty.")
+ if utf8_length(predicate) > self.MAX_PREDICATE_BYTES:
+ raise ValueError("predicate must not exceed {} UTF-8
bytes.".format(self.MAX_PREDICATE_BYTES))
+ self._predicate = predicate
+
+ def get_predicate(self) -> str:
+ return self._predicate
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {self.FIELD_PREDICATE: self._predicate}
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "RowFilter":
+ return cls(data.get(cls.FIELD_PREDICATE))
diff --git a/paimon-python/pypaimon/tests/policy_management_json_test.py
b/paimon-python/pypaimon/tests/policy_management_json_test.py
new file mode 100644
index 0000000000..af30d30b36
--- /dev/null
+++ b/paimon-python/pypaimon/tests/policy_management_json_test.py
@@ -0,0 +1,151 @@
+# 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 DropPolicyRequest, PolicyRequest
+from pypaimon.api.api_response import ListPoliciesResponse
+from pypaimon.common.json_util import JSON
+from pypaimon.management.column_mask import ColumnMask
+from pypaimon.management.data_policy import DataPolicy
+from pypaimon.management.list_policies_request import ListPoliciesRequest
+from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.policy_type import PolicyType
+from pypaimon.management.resource_type import ResourceType
+from pypaimon.management.row_filter import RowFilter
+
+PREDICATE_JSON = (
+ '{"kind":"LEAF","transform":{"name":"FIELD_REF",'
+ '"fieldRef":{"index":0,"name":"region","type":"STRING"}},'
+ '"function":"EQUAL","literals":["APAC"]}')
+TRANSFORM_JSON = (
+ '{"name":"CONCAT","inputs":[{"index":0,"name":"region",'
+ '"type":"STRING"},"****"]}')
+
+
+def catalog_resource():
+ return PermissionResource(ResourceType.CATALOG, None, None, None, None)
+
+
+def table_resource():
+ return PermissionResource(ResourceType.TABLE, "sales", "orders", None,
None)
+
+
+class PolicyManagementJsonTest(unittest.TestCase):
+
+ def test_policy_definitions_round_trip(self):
+ policy = DataPolicy.column_mask(
+ table_resource(), ColumnMask("email", TRANSFORM_JSON), "analyst")
+
+ round_trip = JSON.from_json(JSON.to_json(policy), DataPolicy)
+ self.assertEqual(PolicyType.COLUMN_MASKING, round_trip.type())
+ self.assertEqual(table_resource(), round_trip.get_resource())
+ self.assertEqual("email", round_trip.get_column_mask().get_on_column())
+ self.assertEqual(TRANSFORM_JSON,
round_trip.get_column_mask().get_transform())
+ self.assertEqual("analyst", round_trip.get_principal())
+
+ request = PolicyRequest.from_policy(policy)
+ wire = json.loads(JSON.to_json(request))
+ self.assertEqual(2, len(wire))
+ self.assertIsNotNone(wire["columnMask"])
+ self.assertEqual("analyst", wire["principal"])
+ self.assertEqual(table_resource(),
request.policy(table_resource()).get_resource())
+
+ def test_row_filter_round_trip(self):
+ policy = DataPolicy.row_filter(table_resource(),
RowFilter(PREDICATE_JSON), "analyst")
+
+ round_trip = JSON.from_json(JSON.to_json(policy), DataPolicy)
+ self.assertEqual(PolicyType.ROW_FILTER, round_trip.type())
+ self.assertEqual(PREDICATE_JSON,
round_trip.get_row_filter().get_predicate())
+ self.assertIsNone(round_trip.get_column_mask())
+
+ def test_policy_validation_and_payload_bounds(self):
+ with self.assertRaisesRegex(ValueError, "predicate"):
+ RowFilter(" ")
+ with self.assertRaisesRegex(ValueError, "transform"):
+ ColumnMask("email", " ")
+ with self.assertRaisesRegex(ValueError, "exactly one"):
+ DataPolicy(table_resource(), RowFilter(PREDICATE_JSON),
+ ColumnMask("email", TRANSFORM_JSON), "analyst")
+ with self.assertRaisesRegex(ValueError, "TABLE"):
+ DataPolicy.column_mask(catalog_resource(), ColumnMask("email",
TRANSFORM_JSON), "analyst")
+ with self.assertRaisesRegex(ValueError, "UTF-8 bytes"):
+ RowFilter("p" * (RowFilter.MAX_PREDICATE_BYTES + 1))
+ with self.assertRaisesRegex(ValueError, "UTF-8 bytes"):
+ ColumnMask("email", "t" * (ColumnMask.MAX_TRANSFORM_BYTES + 1))
+
+ def test_drop_policy_request_round_trip_and_identity_validation(self):
+ request = DropPolicyRequest(PolicyType.COLUMN_MASKING, "analyst",
"email")
+ round_trip = JSON.from_json(JSON.to_json(request), DropPolicyRequest)
+
+ self.assertEqual(PolicyType.COLUMN_MASKING, round_trip.get_type())
+ self.assertEqual("analyst", round_trip.get_principal())
+ self.assertEqual("email", round_trip.get_column())
+ with self.assertRaisesRegex(ValueError, "column is required"):
+ DropPolicyRequest(PolicyType.COLUMN_MASKING, "analyst", None)
+ with self.assertRaisesRegex(ValueError, "cannot contain a column"):
+ DropPolicyRequest(PolicyType.ROW_FILTER, "analyst", "email")
+
+ def test_list_policies_validation_and_opaque_page_token(self):
+ request = ListPoliciesRequest(
+ table_resource(), PolicyType.COLUMN_MASKING, "analyst", "email",
None, 25)
+
+ self.assertEqual(" \t", request.with_page_token("
\t").get_page_token())
+ with self.assertRaisesRegex(ValueError, "COLUMN_MASKING"):
+ ListPoliciesRequest(table_resource(), None, None, "email", None,
25)
+ with self.assertRaisesRegex(ValueError, "at most 1000"):
+ ListPoliciesRequest(table_resource(), None, None, None, None, 1001)
+ with self.assertRaisesRegex(ValueError, "TABLE"):
+ ListPoliciesRequest(catalog_resource(), None, None, None, None, 25)
+
+ def test_payload_bounds_count_utf8_bytes(self):
+ RowFilter("\u20ac" * (RowFilter.MAX_PREDICATE_BYTES // 3))
+ with self.assertRaisesRegex(ValueError, "UTF-8 bytes"):
+ RowFilter("\u20ac" * (RowFilter.MAX_PREDICATE_BYTES // 3 + 1))
+ with self.assertRaisesRegex(ValueError, "UTF-8 bytes"):
+ ColumnMask("email", "\U0001F600" * (ColumnMask.MAX_TRANSFORM_BYTES
// 4 + 1))
+ RowFilter("\ud800" * RowFilter.MAX_PREDICATE_BYTES)
+
+ def test_drop_identity_column_follows_java_string_trim(self):
+ self.assertEqual(
+ "\u00a0", DropPolicyRequest(PolicyType.COLUMN_MASKING, "analyst",
"\u00a0").get_column())
+ with self.assertRaisesRegex(ValueError, "cannot contain a column"):
+ DropPolicyRequest(PolicyType.ROW_FILTER, "analyst", "\u00a0")
+ with self.assertRaisesRegex(ValueError, "column is required"):
+ DropPolicyRequest(PolicyType.COLUMN_MASKING, "analyst", "\x00")
+ self.assertIsNone(DropPolicyRequest(PolicyType.ROW_FILTER, "analyst",
"\x00").get_column())
+
+ def test_drop_policy_request_type_is_case_sensitive(self):
+ with self.assertRaisesRegex(ValueError, "column_masking"):
+ JSON.from_json(
+
'{"type":"column_masking","principal":"analyst","column":"email"}',
+ DropPolicyRequest)
+ with self.assertRaisesRegex(ValueError, "policy type cannot be null"):
+ JSON.from_json('{"principal":"analyst"}', DropPolicyRequest)
+
+ def test_listed_policies_are_validated(self):
+ both = json.dumps({"policies": [{
+ "resource": {"type": "TABLE", "database": "sales", "table":
"orders"},
+ "rowFilter": {"predicate": PREDICATE_JSON},
+ "columnMask": {"onColumn": "email", "transform": TRANSFORM_JSON},
+ "principal": "analyst"}]})
+ with self.assertRaisesRegex(ValueError, "exactly one"):
+ JSON.from_json(both, ListPoliciesResponse)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/rest/rest_policy_management_test.py
b/paimon-python/pypaimon/tests/rest/rest_policy_management_test.py
new file mode 100644
index 0000000000..6c6059e8d5
--- /dev/null
+++ b/paimon-python/pypaimon/tests/rest/rest_policy_management_test.py
@@ -0,0 +1,193 @@
+# 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 (AlreadyExistsException,
+ NoSuchResourceException)
+from pypaimon.api.rest_policy_management import RESTPolicyManagement
+from pypaimon.common.json_util import JSON
+from pypaimon.management.column_mask import ColumnMask
+from pypaimon.management.data_policy import DataPolicy
+from pypaimon.management.list_policies_request import ListPoliciesRequest
+from pypaimon.management.permission_resource import PermissionResource
+from pypaimon.management.policy_management import PolicyAlreadyExistException
+from pypaimon.management.policy_type import PolicyType
+from pypaimon.management.resource_type import ResourceType
+
+COLLECTION_PATH = "/v1/catalog+id/databases/sales/tables/orders/policies"
+DROP_PATH = COLLECTION_PATH + "/drop"
+
+
+def table_resource():
+ return PermissionResource(ResourceType.TABLE, "sales", "orders", None,
None)
+
+
+def policy():
+ return DataPolicy.column_mask(
+ table_resource(),
+ ColumnMask("email", '{"name":"FIELD_REF","fieldRef":{"index":0,'
+ '"name":"region","type":"STRING"}}'),
+ "analyst")
+
+
+class RESTPolicyManagementTest(unittest.TestCase):
+
+ def setUp(self):
+ recorded = self.recorded = {"drop_calls": 0, "create_error": None,
"drop_error": None}
+ list_response = '{"policies":[' + JSON.to_json(policy()) +
'],"nextPageToken":"next"}'
+
+ 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):
+ url = urlsplit(self.path)
+ if url.path == COLLECTION_PATH:
+ 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):
+ body = self.rfile.read(int(self.headers.get("Content-Length",
0))).decode("utf-8")
+ path = urlsplit(self.path).path
+ if path == COLLECTION_PATH:
+ recorded["create_body"] = body
+ error = recorded["create_error"]
+ self.respond(409, error) if error else self.respond(200)
+ elif path == DROP_PATH:
+ recorded["drop_body"] = body
+ recorded["drop_calls"] += 1
+ error = recorded["drop_error"]
+ self.respond(404, error) if error else 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()
+ options = {
+ "uri": "http://127.0.0.1:{}".format(self.server.server_port),
+ "token.provider": "bear",
+ "token": "secret",
+ "prefix": "catalog id",
+ }
+ self.api = RESTApi(options, False)
+ self.management = RESTPolicyManagement(self.api)
+
+ def tearDown(self):
+ self.server.shutdown()
+ self.server.server_close()
+
+ def drop(self, ignore_if_not_exists):
+ dropped = policy()
+ self.management.drop_policy(
+ dropped.get_resource(), dropped.type(), dropped.get_principal(),
+ dropped.get_column_mask().get_on_column(), ignore_if_not_exists)
+
+ def test_policies_are_nested_under_attachment_resource(self):
+ paths = ResourcePaths("catalog/id")
+ catalog = PermissionResource(ResourceType.CATALOG, None, None, None,
None)
+ database = PermissionResource(ResourceType.DATABASE, "sales db", None,
None, None)
+ table = PermissionResource(ResourceType.TABLE, "sales db",
"orders/all", None, None)
+
+ for resource in (catalog, database):
+ with self.assertRaisesRegex(ValueError, "TABLE"):
+ paths.policies(resource)
+ self.assertEqual(
+ "/v1/catalog%2Fid/databases/sales+db/tables/orders%2Fall/policies",
+ paths.policies(table))
+ self.assertEqual(
+
"/v1/catalog%2Fid/databases/sales+db/tables/orders%2Fall/policies/drop",
+ paths.drop_policy(table))
+
+ def test_list_uses_resource_nested_path_and_identity_filters(self):
+ policies = self.management.list_policies(ListPoliciesRequest(
+ table_resource(), PolicyType.COLUMN_MASKING, "analyst", "email",
"start", 25))
+
+ self.assertEqual(1, len(policies.elements))
+ self.assertEqual("next", policies.next_page_token)
+ self.assertEqual(
+ {"type": "COLUMN_MASKING", "principal": "analyst", "column":
"email",
+ "maxResults": "25", "pageToken": "start"},
+ self.recorded["list_query"])
+
+ def test_list_sends_a_whitespace_page_token_verbatim(self):
+ request = ListPoliciesRequest(table_resource(), page_token=" \t")
+ self.management.list_policies(request)
+ self.assertEqual({"pageToken": " \t"}, self.recorded["list_query"])
+
+ self.management.list_policies(request.with_page_token(""))
+ self.assertEqual({}, self.recorded["list_query"])
+
+ def test_create_and_drop_use_post_endpoints(self):
+ self.management.create_policy(policy())
+ self.drop(False)
+
+ create = json.loads(self.recorded["create_body"])
+ self.assertEqual("analyst", create["principal"])
+ self.assertNotIn("resource", create)
+ drop = json.loads(self.recorded["drop_body"])
+ self.assertEqual("COLUMN_MASKING", drop["type"])
+ self.assertEqual("email", drop["column"])
+ self.assertEqual(1, self.recorded["drop_calls"])
+
+ def test_create_maps_only_policy_conflict(self):
+ self.recorded["create_error"] = (
+
'{"resourceType":"POLICY","resourceName":"COLUMN_MASKING:analyst:email",'
+ '"message":"already exists","code":409}')
+ with self.assertRaisesRegex(PolicyAlreadyExistException,
r"COLUMN_MASKING\(email\)") as ctx:
+ self.management.create_policy(policy())
+ self.assertIsInstance(ctx.exception.__cause__, AlreadyExistsException)
+
+ self.recorded["create_error"] = (
+ '{"resourceType":"TABLE","resourceName":"orders",'
+ '"message":"table conflict","code":409}')
+ with self.assertRaisesRegex(AlreadyExistsException, "table conflict"):
+ self.management.create_policy(policy())
+
+ def test_drop_if_exists_only_ignores_missing_policy(self):
+ self.recorded["drop_error"] = (
+
'{"resourceType":"POLICY","resourceName":"COLUMN_MASKING:analyst:email",'
+ '"message":"missing","code":404}')
+ self.drop(True)
+ with self.assertRaises(NoSuchResourceException):
+ self.drop(False)
+
+ self.recorded["drop_error"] = (
+ '{"resourceType":"TABLE","resourceName":"orders",'
+ '"message":"missing table","code":404}')
+ with self.assertRaisesRegex(NoSuchResourceException, "missing table"):
+ self.drop(True)
+
+
+if __name__ == '__main__':
+ unittest.main()