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 1e64b63083 [python] Add data policy endpoints to the REST mock server 
(#10618)
1e64b63083 is described below

commit 1e64b63083950dd08cd6e93b85e2b7c37f8c8231
Author: Jiajia Li <[email protected]>
AuthorDate: Sun Oct 18 18:53:17 2026 +0800

    [python] Add data policy endpoints to the REST mock server (#10618)
---
 .../tests/rest/rest_permission_server_test.py      | 373 ++++++++-
 paimon-python/pypaimon/tests/rest/rest_server.py   | 838 ++++++++++++++++++++-
 .../pypaimon/tests/sql_permission_test.py          | 110 ++-
 3 files changed, 1283 insertions(+), 38 deletions(-)

diff --git a/paimon-python/pypaimon/tests/rest/rest_permission_server_test.py 
b/paimon-python/pypaimon/tests/rest/rest_permission_server_test.py
index 51db1276d6..4d24b3a93f 100644
--- a/paimon-python/pypaimon/tests/rest/rest_permission_server_test.py
+++ b/paimon-python/pypaimon/tests/rest/rest_permission_server_test.py
@@ -15,6 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import json
+
 import pyarrow as pa
 import pytest
 
@@ -22,14 +24,21 @@ from pypaimon import CatalogFactory, Schema
 from pypaimon.api.api_response import ConfigResponse
 from pypaimon.api.auth import BearTokenAuthProvider
 from pypaimon.api.rest_exception import (AlreadyExistsException, 
BadRequestException,
-                                         NoSuchResourceException)
-from pypaimon.catalog.catalog_exception import IllegalArgumentError, 
TableNoPermissionException
+                                         ForbiddenException, 
NoSuchResourceException)
+from pypaimon.catalog.catalog_exception import (IllegalArgumentError, 
TableNoPermissionException,
+                                                TableNotExistException)
 from pypaimon.common.identifier import Identifier
+from pypaimon.management.column_mask import ColumnMask
+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_columns import PermissionColumns
 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
+from pypaimon.schema.data_types import AtomicType
 from pypaimon.schema.schema_change import SchemaChange
 from pypaimon.tests.rest.rest_function_test import _mock_function
 from pypaimon.tests.rest.rest_server import RESTCatalogServer
@@ -37,7 +46,8 @@ from pypaimon.tests.rest.rest_server import RESTCatalogServer
 USERS = Identifier.create('default', 'users')
 MEMBERS = Identifier.create('default', 'members')
 COLUMNS = PermissionResource(ResourceType.COLUMN, 'default', 'users')
-ARROW_SCHEMA = pa.schema([('id', pa.int64()), ('email', pa.string()), 
('phone', pa.string())])
+ARROW_SCHEMA = pa.schema([('id', pa.int64()), ('email', pa.string()), 
('joined', pa.date32()), ('score', pa.float64()),
+                          ('tags', pa.list_(pa.int32())), ('phone', 
pa.string())])
 
 
 def _schema(query_auth=True):
@@ -48,6 +58,7 @@ def _schema(query_auth=True):
 def rest(tmp_path):
     server = RESTCatalogServer(str(tmp_path), BearTokenAuthProvider('token'),
                                ConfigResponse(defaults={'prefix': 'mock'}), 
'warehouse')
+    server.register_management_principal('analyst', 'auditor', 'reader', 
'writer')
     server.start()
     try:
         catalog = CatalogFactory.create({
@@ -186,7 +197,7 @@ def test_column_grants_follow_column_renames(rest):
 
     table = catalog.get_table('default.users')
     for schema in (table.table_schema, table.schema_manager.latest()):
-        assert [field.name for field in schema.fields] == ['contact', 'phone']
+        assert [field.name for field in schema.fields] == ['contact', 
'joined', 'score', 'tags', 'phone']
         assert schema.options['query-auth.enabled'] == 'true'
     assert _column_grants(catalog) == [(('contact',), None)]
 
@@ -217,3 +228,357 @@ def 
test_tables_with_branches_cannot_have_column_grants(rest):
     catalog.permission_management().grant_permission(grant)
     with pytest.raises(IllegalArgumentError):
         catalog.create_branch(USERS, 'b1')
+
+
+STARS_MASK = json.dumps({'name': 'CONCAT', 'inputs': ['***']})
+LOWER_MASK = json.dumps({'name': 'LOWER', 'inputs': [{'name': 'email', 'type': 
'STRING'}]})
+
+
+def _row_filter(transform, column, column_type, function='EQUAL', literal=1):
+    return RowFilter(json.dumps({'kind': 'LEAF', 'function': function, 
'literals': [literal], 'transform': {
+        'name': transform, 'fieldRef': {'name': column, 'type': 
column_type}}}))
+
+
+def _mask(column, transform, principal='analyst', table='users'):
+    return DataPolicy.column_mask(_table(table), ColumnMask(column, 
transform), principal)
+
+
+def _policies(catalog, name):
+    policies = 
catalog.policy_management().list_policies(ListPoliciesRequest(_table(name))).elements
+    return [(policy.get_resource().get_table(), 
policy.get_column_mask().get_on_column()) for policy in policies]
+
+
+def test_table_policies_follow_rename_and_drop(rest):
+    catalog, _ = rest
+    catalog.policy_management().create_policy(_mask('email', STARS_MASK))
+
+    catalog.rename_table('default.users', 'default.members')
+    assert _policies(catalog, 'members') == [('members', 'email')]
+    catalog.create_table('default.users', _schema(), False)
+    assert _policies(catalog, 'users') == []
+
+    catalog.drop_table('default.members')
+    catalog.create_table('default.members', _schema(), False)
+    assert _policies(catalog, 'members') == []
+
+
+def test_dropping_a_database_removes_only_its_tables_and_policies(rest):
+    catalog, server = rest
+    for database in ('sales', 'sales.eu'):
+        catalog.create_database(database, False)
+    tables = [PermissionResource(ResourceType.TABLE, 'sales', 'eu.users'),
+              PermissionResource(ResourceType.TABLE, 'sales.eu', 'orders')]
+    for table in tables:
+        catalog.create_table(Identifier.create(table.get_database(), 
table.get_table()), _schema(), False)
+        catalog.policy_management().create_policy(
+            DataPolicy.column_mask(table, ColumnMask('email', STARS_MASK), 
'analyst'))
+
+    assert [catalog.list_tables(database) for database in ('sales', 
'sales.eu')] == [['eu.users'], ['orders']]
+    with pytest.raises(ValueError):
+        catalog.drop_database('sales.eu', False, False)
+    catalog.drop_database('sales', False, True)
+    catalog.create_database('sales', False)
+    with pytest.raises(TableNotExistException):
+        catalog.get_table(Identifier.create('sales', 'eu.users'))
+    assert 
len(catalog.policy_management().list_policies(ListPoliciesRequest(tables[1])).elements)
 == 1
+    catalog.drop_database('sales.eu', False, True)
+    assert server.policy_store == {}
+
+
[email protected]('drop_database', [False, True])
+def test_a_recreated_table_starts_from_its_own_schema(rest, drop_database):
+    catalog, _ = rest
+    catalog.alter_table('default.users', [SchemaChange.set_option('a', 'b')])
+    if drop_database:
+        catalog.drop_database('default', False, True)
+        catalog.create_database('default', False)
+    else:
+        catalog.drop_table('default.users')
+    catalog.create_table('default.users', 
Schema.from_pyarrow_schema(pa.schema([('id', pa.int64())])), False)
+    catalog.alter_table('default.users', [SchemaChange.set_option('a', 'b')])
+    assert [field.name for field in 
catalog.get_table('default.users').table_schema.fields] == ['id']
+
+
[email protected]('branch', ['b1', 'missing'])
+def test_a_branch_is_not_dropped_as_a_table(rest, branch):
+    catalog, _ = rest
+    catalog.create_branch(USERS, 'b1')
+    with pytest.raises(TableNotExistException):
+        catalog.drop_table(Identifier('default', 'users', branch=branch))
+    catalog.alter_table('default.users', [SchemaChange.set_option('a', 'b')])
+    assert catalog.get_table('default.users').table_schema.options['a'] == 'b'
+    assert catalog.list_branches(USERS) == ['b1']
+
+
+def test_policies_are_managed_without_read_access(rest):
+    catalog, server = rest
+    server.add_no_permission_database('default')
+    server.add_no_permission_table(USERS)
+    catalog.policy_management().create_policy(_mask('email', STARS_MASK))
+    assert _policies(catalog, 'users') == [('users', 'email')]
+    catalog.policy_management().drop_policy(_table('users'), 
PolicyType.COLUMN_MASKING, 'analyst', 'email', False)
+    assert _policies(catalog, 'users') == []
+
+
+def test_management_requests_check_principals_and_management_permission(rest):
+    catalog, server = rest
+    permissions, policies = catalog.permission_management(), 
catalog.policy_management()
+    for request in (lambda: 
permissions.grant_permission(PermissionAssignment(_table('users'), 'SELECT', 
'nobody')),
+                    lambda: permissions.revoke_permission(_table('users'), 
'SELECT', 'nobody'),
+                    lambda: permissions.list_permissions(
+                        ListPermissionsRequest(ResourceType.TABLE, 'default', 
'users', principal='nobody')),
+                    lambda: policies.create_policy(_mask('email', STARS_MASK, 
'nobody')),
+                    lambda: 
policies.list_policies(ListPoliciesRequest(_table('users'), 
principal='nobody'))):
+        with pytest.raises(NoSuchResourceException) as error:
+            request()
+        assert error.value.resource_type == 'PRINCIPAL'
+    permissions.grant_permission(PermissionAssignment(_table('users'), 
'SELECT', 'analyst'))
+    policies.create_policy(_mask('email', STARS_MASK))
+
+    server.deny_management_permission(_table('users'))
+    drop = (_table('users'), PolicyType.COLUMN_MASKING, 'analyst', 'email', 
False)
+    for request in (lambda: 
permissions.grant_permission(PermissionAssignment(_table('users'), 'SELECT', 
'auditor')),
+                    lambda: permissions.revoke_permission(_table('users'), 
'SELECT', 'analyst'),
+                    lambda: _grants(catalog, 'users'), lambda: 
_policies(catalog, 'users'),
+                    lambda: policies.create_policy(_mask('phone', STARS_MASK)),
+                    lambda: policies.drop_policy(*drop)):
+        with pytest.raises(ForbiddenException):
+            request()
+    assert [len(server.permission_store), [len(table) for table in 
server.policy_store.values()]] == [1, [1]]
+
+
+def test_policies_need_query_auth_and_existing_columns(rest):
+    catalog, _ = rest
+    catalog.create_table('default.plain', _schema(query_auth=False), False)
+    with pytest.raises(AlreadyExistsException):
+        catalog.policy_management().create_policy(_mask('email', STARS_MASK, 
table='plain'))
+    for policy in (_mask('mobile', STARS_MASK),
+                   DataPolicy.row_filter(_table('users'), 
_row_filter('FIELD_REF', 'mobile', 'BIGINT'), 'analyst')):
+        with pytest.raises(BadRequestException):
+            catalog.policy_management().create_policy(policy)
+
+
[email protected]('policy, column, other', [
+    (_mask('email', STARS_MASK), 'email', 'id'),
+    (DataPolicy.row_filter(_table('users'), _row_filter('FIELD_REF', 'id', 
'BIGINT'), 'analyst'), 'id', 'email'),
+    (DataPolicy.row_filter(_table('users'), _row_filter('YEAR', 'joined', 
'DATE'), 'analyst'), 'joined', 'id'),
+])
+def test_schema_changes_cannot_orphan_policies(rest, policy, column, other):
+    catalog, _ = rest
+    catalog.policy_management().create_policy(policy)
+    schema = catalog.get_table('default.users').table_schema
+    column_type = next(field.type for field in schema.fields if field.name == 
column)
+    for changes in ([SchemaChange.set_option('query-auth.enabled', 'false')],
+                    [SchemaChange.remove_option('query-auth.enabled')],
+                    [SchemaChange.rename_column(column, 'renamed')],
+                    [SchemaChange.drop_column(column)],
+                    [SchemaChange.rename_column(column, 'renamed'), 
SchemaChange.add_column(column, column_type)]):
+        with pytest.raises(BadRequestException):
+            catalog.alter_table('default.users', changes)
+
+    current = catalog.get_table('default.users').table_schema
+    assert (current.id, current.options) == (schema.id, schema.options)
+    policies = 
catalog.policy_management().list_policies(ListPoliciesRequest(_table('users'))).elements
+    assert [stored.type() for stored in policies] == [policy.type()]
+    catalog.alter_table('default.users', [SchemaChange.rename_column(other, 
'renamed')])
+
+
+def test_policy_expressions_must_fit_the_table_schema(rest):
+    catalog, _ = rest
+    policies = catalog.policy_management()
+    email, joined = {'name': 'email', 'type': 'STRING'}, {'name': 'joined', 
'type': 'DATE'}
+    masks = [('email', {'name': 'NULL'}), ('email', {'name': 'TRIM', 'inputs': 
[email]}),
+             ('email', {'name': 'SUBSTRING', 'inputs': [123, 1]}), ('email', 
{'name': 'YEAR', 'fieldRef': joined}),
+             ('email', {'name': 'LOWER', 'inputs': [{'name': 'id', 'type': 
'BIGINT'}]}),
+             ('joined', {'name': 'CAST', 'fieldRef': joined, 'type': 'DATE'}),
+             ('email', {'name': 'CAST', 'fieldRef': {'name': 'id', 'type': 
'BIGINT'}, 'type': 'VARCHAR(2147483647)'}),
+             ('email', {'name': 'CONCAT', 'inputs': email}), ('email', 
{'name': 'CONCAT', 'inputs': None}),
+             ('email', {'name': 'CONCAT', 'inputs': [{'name': 'email', 'type': 
'INT'}, email]})]
+    row_filters = [RowFilter(json.dumps(predicate)) for predicate in (
+        None, {}, {'kind': 'COMPOUND', 'function': 'AND', 'children': []})] + [
+        _row_filter('FIELD_REF', 'id', 'BIGINT', literal=float('nan')),
+        _row_filter('YEAR', 'email', 'STRING'), _row_filter('FIELD_REF', 'id', 
'BIGINT', literal='one'),
+        _row_filter('FIELD_REF', 'id', 'BIGINT', function='NOT_REAL'),
+        _row_filter('FIELD_REF', 'id', 'INTEGER'),
+        _row_filter('FIELD_REF', 'email', 'STRING', literal=1.5),
+        _row_filter('FIELD_REF', 'score', 'FLOAT', literal=1e11),
+        _row_filter('FIELD_REF', 'joined', 'DATE', literal='2023-02-29'),
+        RowFilter(json.dumps({'kind': 'LEAF', 'function': 'IS_NULL', 
'literals': [], 'transform': {
+            'name': 'CAST', 'fieldRef': {'name': 'score', 'type': 'DOUBLE'}, 
'type': 'TIMESTAMP(6)'}})),
+        RowFilter(json.dumps({'kind': 'LEAF', 'function': 'IS_NULL', 
'literals': [], 'transform': {
+            'name': 'CAST', 'fieldRef': email, 'type': {'type': 'ARRAY', 
'element': {
+                'type': 'VECTOR', 'element': 'FLOAT', 'length': 3}}}}))]
+    for policy in ([_mask(column, json.dumps(transform)) for column, transform 
in masks]
+                   + [DataPolicy.row_filter(_table('users'), row_filter, 
'analyst') for row_filter in row_filters]):
+        with pytest.raises(BadRequestException):
+            policies.create_policy(policy)
+
+    policies.create_policy(_mask('email', LOWER_MASK))
+    policies.create_policy(_mask('email', json.dumps(
+        {'name': 'CAST', 'fieldRef': {'name': 'id', 'type': 'BIGINT'}, 'type': 
'STRING'}), 'auditor'))
+    policies.create_policy(DataPolicy.row_filter(_table('users'), 
_row_filter('YEAR', 'joined', 'DATE'), 'analyst'))
+    policies.create_policy(DataPolicy.row_filter(
+        _table('users'), _row_filter('FIELD_REF', 'joined', 'DATE', 
literal='2024-02-29'), 'auditor'))
+    policies.create_policy(DataPolicy.row_filter(_table('users'), 
RowFilter(json.dumps(
+        {'kind': 'LEAF', 'function': 'EQUAL', 'literals': 
['2024-01-01T00:00'], 'transform': {
+            'name': 'CAST', 'fieldRef': {'name': 'id', 'type': 'BIGINT'}, 
'type': 'TIMESTAMP(6)'}})), 'reader'))
+    policies.create_policy(DataPolicy.row_filter(
+        _table('users'), _row_filter('FIELD_REF', 'score', 'FLOAT', 
literal=1.5), 'writer'))
+    for column, new_type in (('email', 'BIGINT'), ('joined', 'STRING')):
+        with pytest.raises(BadRequestException):
+            catalog.alter_table('default.users', 
[SchemaChange.update_column_type(column, AtomicType(new_type))])
+
+
+def test_float_literals_block_widening_unless_exact(rest):
+    catalog, _ = rest
+    catalog.create_table('default.metrics', Schema.from_pyarrow_schema(
+        pa.schema([('v', pa.float32())]), options={'query-auth.enabled': 
'true'}), False)
+    policies = catalog.policy_management()
+    widen = [SchemaChange.update_column_type('v', AtomicType('DOUBLE'))]
+
+    policies.create_policy(DataPolicy.row_filter(
+        _table('metrics'), _row_filter('FIELD_REF', 'v', 'FLOAT', 
literal=1e11), 'analyst'))
+    with pytest.raises(BadRequestException):
+        catalog.alter_table('default.metrics', widen)
+
+    policies.drop_policy(_table('metrics'), PolicyType.ROW_FILTER, 'analyst', 
None, False)
+    policies.create_policy(DataPolicy.row_filter(
+        _table('metrics'), _row_filter('FIELD_REF', 'v', 'FLOAT', 
literal=1.5), 'analyst'))
+    catalog.alter_table('default.metrics', widen)
+    assert [field.type.type for field in 
catalog.get_table('default.metrics').table_schema.fields] == ['DOUBLE']
+
+
+def test_instants_take_a_leap_second_only_at_the_end_of_a_day(rest):
+    catalog, _ = rest
+    catalog.create_table('default.events', Schema.from_pyarrow_schema(
+        pa.schema([('at', pa.timestamp('us', tz='UTC'))]), 
options={'query-auth.enabled': 'true'}), False)
+    policies = catalog.policy_management()
+
+    with pytest.raises(BadRequestException):
+        policies.create_policy(DataPolicy.row_filter(_table('events'), 
_row_filter(
+            'FIELD_REF', 'at', 'TIMESTAMP_LTZ(6)', 
literal='2024-01-01T12:34:60Z'), 'analyst'))
+    policies.create_policy(DataPolicy.row_filter(_table('events'), _row_filter(
+        'FIELD_REF', 'at', 'TIMESTAMP_LTZ(6)', 
literal='2016-12-31T23:59:60Z'), 'analyst'))
+    stored = 
policies.list_policies(ListPoliciesRequest(_table('events'))).elements[0]
+    assert json.loads(stored.get_row_filter().get_predicate())['literals'] == 
['2016-12-31T23:59:59Z']
+
+
+def test_temporal_literals_keep_to_the_years_java_writes(rest):
+    catalog, _ = rest
+    catalog.create_table('default.events', 
Schema.from_pyarrow_schema(pa.schema(
+        [('at', pa.timestamp('us', tz='UTC')), ('ts', pa.timestamp('us')), 
('day', pa.date32())]),
+        options={'query-auth.enabled': 'true'}), False)
+    policies = catalog.policy_management()
+
+    for column, column_type, literal in (
+            ('at', 'TIMESTAMP_LTZ(6)', '+2024-01-01T00:00:00Z'), ('at', 
'TIMESTAMP_LTZ(6)', '10000-01-01T00:00:00Z'),
+            ('at', 'TIMESTAMP_LTZ(6)', '-0000-01-01T00:00:00Z'),
+            ('at', 'TIMESTAMP_LTZ(6)', '+1000000000-01-01T00:00:00Z'),
+            ('ts', 'TIMESTAMP(6)', '-0000-01-01T00:00'), ('ts', 
'TIMESTAMP(6)', '+5881580-07-12T00:00'),
+            ('day', 'DATE', '-0000-01-01')):
+        with pytest.raises(BadRequestException):
+            policies.create_policy(DataPolicy.row_filter(
+                _table('events'), _row_filter('FIELD_REF', column, 
column_type, literal=literal), 'analyst'))
+    for principal, column, column_type, literal in (
+            ('analyst', 'at', 'TIMESTAMP_LTZ(6)', '+10000-01-01T00:00:00Z'),
+            ('auditor', 'ts', 'TIMESTAMP(6)', '+5881580-07-11T00:00')):
+        policies.create_policy(DataPolicy.row_filter(
+            _table('events'), _row_filter('FIELD_REF', column, column_type, 
literal=literal), principal))
+    assert 
sorted(json.loads(policy.get_row_filter().get_predicate())['literals'][0]
+                  for policy in 
policies.list_policies(ListPoliciesRequest(_table('events'))).elements) == [
+        '+10000-01-01T00:00:00Z', '+5881580-07-11T00:00']
+
+
+def test_policy_listings_are_paged(rest):
+    catalog, server = rest
+    principals = ['user{:03}'.format(i) for i in range(102)]
+    server.register_management_principal(*principals)
+    for principal in principals:
+        catalog.policy_management().create_policy(_mask('email', STARS_MASK, 
principal))
+
+    first = 
catalog.policy_management().list_policies(ListPoliciesRequest(_table('users'), 
max_results=1))
+    second = catalog.policy_management().list_policies(
+        ListPoliciesRequest(_table('users'), page_token=first.next_page_token, 
max_results=1000))
+    assert [len(first.elements), len(second.elements), second.next_page_token] 
== [1, 101, None]
+    assert [policy.get_principal() for policy in first.elements + 
second.elements] == principals
+
+
+def test_policies_are_stored_as_java_serializes_them(rest):
+    catalog, server = rest
+    leaves = [{'kind': 'LEAF', 'function': 'IN', 'literals': [1.9, 2],
+               'transform': {'name': 'FIELD_REF', 'fieldRef': {'name': 'id', 
'type': 'BIGINT'}}},
+              {'kind': 'LEAF', 'function': 'GREATER_THAN', 'literals': 
['2024-02-29'],
+               'transform': {'name': 'FIELD_REF', 'fieldRef': {'name': 
'joined', 'type': 'DATE'}}},
+              {'kind': 'LEAF', 'function': 'EQUAL', 'literals': [1e10], 
'transform': {
+                  'name': 'CAST', 'fieldRef': {'name': 'id', 'type': 
'BIGINT'}, 'type': 'INT'}},
+              {'kind': 'LEAF', 'function': 'EQUAL', 'literals': ['2024-02-29'],
+               'transform': {'name': 'FIELD_REF', 'fieldRef': {'name': 'id', 
'type': 'DATE'}}}]
+    leaves[1] = {'kind': 'COMPOUND', 'function': 'OR', 'children': [leaves[1]]}
+    
catalog.policy_management().create_policy(DataPolicy.row_filter(_table('users'),
 RowFilter(json.dumps(
+        {'kind': 'COMPOUND', 'function': 'AND', 'children': leaves})), 
'analyst'))
+    catalog.policy_management().create_policy(_mask('email', LOWER_MASK))
+    catalog.policy_management().create_policy(_mask('tags', json.dumps(
+        {'name': 'CAST', 'fieldRef': {'name': 'email', 'type': 'STRING'},
+         'type': {'type': 'ARRAY', 'element': 'INT'}})))
+
+    server.set_query_principals('analyst')
+    auth = catalog.auth_table_query(USERS)
+    assert auth.filter == [
+        '{"kind":"COMPOUND","function":"AND","children":['
+        
'{"kind":"LEAF","transform":{"name":"FIELD_REF","fieldRef":{"index":0,"name":"id","type":"BIGINT"}},'
+        '"function":"IN","literals":[1,2]},'
+        
'{"kind":"LEAF","transform":{"name":"FIELD_REF","fieldRef":{"index":2,"name":"joined","type":"DATE"}},'
+        '"function":"GREATER_THAN","literals":["2024-02-29"]},'
+        
'{"kind":"LEAF","transform":{"name":"CAST","fieldRef":{"index":0,"name":"id","type":"BIGINT"},"type":"INT"},'
+        '"function":"EQUAL","literals":[2147483647]},'
+        
'{"kind":"LEAF","transform":{"name":"FIELD_REF","fieldRef":{"index":0,"name":"id","type":"BIGINT"}},'
+        '"function":"EQUAL","literals":[19782]}]}']
+    assert auth.column_masking == {
+        'email': 
'{"name":"LOWER","inputs":[{"index":1,"name":"email","type":"STRING"}]}',
+        'tags': 
'{"name":"CAST","fieldRef":{"index":1,"name":"email","type":"STRING"},'
+                '"type":{"type":"ARRAY","element":"INT"}}'}
+
+
+def test_query_auth_returns_policies_for_the_current_schema(rest):
+    catalog, server = rest
+    catalog.policy_management().create_policy(
+        DataPolicy.row_filter(_table('users'), _row_filter('FIELD_REF', 'id', 
'BIGINT'), 'analyst'))
+    catalog.policy_management().create_policy(
+        DataPolicy.row_filter(_table('users'), _row_filter('FIELD_REF', 
'score', 'DOUBLE', literal=1.5), 'auditor'))
+    for column, column_type in (('id', 'DECIMAL(20, 0)'), ('score', 'STRING')):
+        with pytest.raises(BadRequestException):
+            catalog.alter_table('default.users', 
[SchemaChange.update_column_type(column, AtomicType(column_type))])
+    catalog.alter_table('default.users', 
[SchemaChange.update_column_type('id', AtomicType('STRING'))])
+
+    server.set_query_principals('analyst')
+    assert catalog.auth_table_query(USERS).filter == [
+        
'{"kind":"LEAF","transform":{"name":"FIELD_REF","fieldRef":{"index":0,"name":"id","type":"STRING"}},'
+        '"function":"EQUAL","literals":["1"]}']
+
+
+def test_masks_of_two_query_principals_on_one_column_deny_the_query(rest):
+    catalog, server = rest
+    for principal, transform in (('analyst', STARS_MASK), ('auditor', 
LOWER_MASK)):
+        catalog.policy_management().create_policy(_mask('email', transform, 
principal))
+
+    server.set_query_principals('analyst')
+    assert catalog.auth_table_query(USERS).column_masking == {'email': 
'{"name":"CONCAT","inputs":["***"]}'}
+    server.set_query_principals('analyst', 'auditor')
+    with pytest.raises(TableNoPermissionException):
+        catalog.auth_table_query(USERS)
+
+
+def test_tables_with_branches_cannot_have_policies(rest):
+    catalog, _ = rest
+    with pytest.raises(NoSuchResourceException):
+        catalog.policy_management().create_policy(_mask('email', STARS_MASK, 
table='users$branch_missing'))
+    with pytest.raises(TableNotExistException):
+        catalog.auth_table_query(Identifier('default', 'users', 
branch='missing'))
+    catalog.create_branch(USERS, 'b1')
+    with pytest.raises(BadRequestException):
+        catalog.policy_management().create_policy(_mask('email', STARS_MASK))
+
+    catalog.drop_branch(USERS, 'b1')
+    catalog.policy_management().create_policy(_mask('email', STARS_MASK))
+    with pytest.raises(IllegalArgumentError):
+        catalog.create_branch(USERS, 'b1')
diff --git a/paimon-python/pypaimon/tests/rest/rest_server.py 
b/paimon-python/pypaimon/tests/rest/rest_server.py
index e61ea4388b..33bd32404f 100755
--- a/paimon-python/pypaimon/tests/rest/rest_server.py
+++ b/paimon-python/pypaimon/tests/rest/rest_server.py
@@ -15,11 +15,16 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import base64
+import decimal
 import logging
+import math
 import re
+import struct
 import threading
 import time
 import uuid
+from collections import namedtuple
 from http.server import BaseHTTPRequestHandler, HTTPServer
 from pathlib import Path
 import json
@@ -34,7 +39,8 @@ from pypaimon.api.api_request import (AlterDatabaseRequest, 
AlterTableRequest,
                                       CreateDatabaseRequest,
                                       CreatePartitionsRequest,
                                       CreateTableRequest, CreateTagRequest,
-                                      GrantPermissionRequest,
+                                      DropPolicyRequest,
+                                      GrantPermissionRequest, PolicyRequest,
                                       RenameTableRequest,
                                       RevokePermissionRequest)
 from pypaimon.api.api_response import (ConfigResponse, 
CreatePartitionsResponse,
@@ -47,7 +53,8 @@ from pypaimon.api.api_response import (ConfigResponse, 
CreatePartitionsResponse,
                                        ListFunctionsGloballyResponse,
                                        ListFunctionsResponse,
                                        ListPartitionsResponse,
-                                       ListPermissionsResponse, 
ListTablesResponse,
+                                       ListPermissionsResponse,
+                                       ListPoliciesResponse, 
ListTablesResponse,
                                        ListTagsResponse,
                                        PagedList, Partition,
                                        RESTResponse, ErrorResponse)
@@ -66,18 +73,26 @@ from pypaimon.catalog.catalog_exception import 
(BranchAlreadyExistException,
                                                 DefinitionNotExistException,
                                                 TagNotExistException,
                                                 TagAlreadyExistException)
+from pypaimon.casting import data_type_casts as casts
 from pypaimon.catalog.rest.table_metadata import TableMetadata
 from pypaimon.common.identifier import Identifier
 from pypaimon.api.typedef import RESTAuthParameter
 from pypaimon.common.json_util import JSON
 from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.management.column_mask import ColumnMask
+from pypaimon.management.data_policy import DataPolicy
+from pypaimon.management.list_permissions_request import ListPermissionsRequest
 from pypaimon.management.permission_access import PermissionAccess
 from pypaimon.management.permission_assignment import (PermissionAssignment, 
_INSTANT,
-                                                       _days_from_civil)
+                                                       _days_from_civil, 
_days_in_month,
+                                                       _parse_instant as 
_instant_nanos)
 from pypaimon.management.permission_columns import PermissionColumns
 from pypaimon.management.permission_resource import PermissionResource
 from pypaimon.management.resource_type import ResourceType
+from pypaimon.management.row_filter import RowFilter
 from pypaimon import Schema
+from pypaimon.schema.data_types import (ArrayType, AtomicType, DataField, 
DataType, DataTypeParser, MapType,
+                                        MultisetType, RowType, VectorType)
 from pypaimon.schema.schema_change import Actions, SchemaChange
 from pypaimon.schema.schema_manager import SchemaManager
 from pypaimon.schema.table_schema import TableSchema
@@ -227,6 +242,599 @@ def _resource_name(resource: PermissionResource) -> str:
     return ".".join(part for part in (resource.get_database(), name) if part) 
or "catalog"
 
 
+def _policy_identity(policy: DataPolicy) -> Tuple[str, str, Optional[str]]:
+    mask = policy.get_column_mask()
+    return policy.type().name, policy.get_principal(), None if mask is None 
else mask.get_on_column()
+
+
+def _policy_key(policy: DataPolicy) -> str:
+    return _cursor_key(*_policy_identity(policy))
+
+
+def _policy_name(policy_type: str, principal: str, column: Optional[str]) -> 
str:
+    return ":".join(part for part in (policy_type, principal, column) if part 
is not None)
+
+
+def _field_refs(transform: Dict[str, Any]) -> List[Dict[str, Any]]:
+    refs = list(transform.get("inputs") or []) + [transform.get("fieldRef")]
+    return [ref for ref in refs if isinstance(ref, dict)]
+
+
+def _leaves(predicate: Dict[str, Any]) -> List[Dict[str, Any]]:
+    if predicate["kind"] == "COMPOUND":
+        children = predicate.get("children")
+        if predicate.get("function") not in ("AND", "OR") or not children or 
None in children:
+            raise ValueError("Compound predicate needs AND or OR and at least 
one predicate.")
+        return [leaf for child in children for leaf in _leaves(child)]
+    if predicate["kind"] != "LEAF" or not 
isinstance(predicate.get("transform"), dict):
+        raise ValueError("Unknown predicate {}.".format(json.dumps(predicate)))
+    return [predicate]
+
+
+def _reject_constant(token: str) -> None:
+    raise ValueError("Non-standard JSON token {}.".format(token))
+
+
+def _policy_json(text: str) -> Any:
+    return json.loads(text, parse_constant=_reject_constant)
+
+
+def _policy_transforms(policy: DataPolicy) -> List[Tuple[Dict[str, Any], 
Optional[Dict[str, Any]]]]:
+    mask = policy.get_column_mask()
+    if mask is not None:
+        return [(_policy_json(mask.get_transform()), None)]
+    return [(leaf["transform"], leaf) for leaf in 
_leaves(_policy_json(policy.get_row_filter().get_predicate()))]
+
+
+def _policy_columns(policy: DataPolicy) -> set:
+    mask = policy.get_column_mask()
+    columns = {ref["name"] for transform, _ in _policy_transforms(policy) for 
ref in _field_refs(transform)}
+    return columns | ({mask.get_on_column()} if mask is not None else set())
+
+
+_DATE_PARTS = ("YEAR", "QUARTER", "MONTH", "WEEK", "YEAR_OF_WEEK", "DAY", 
"DAY_OF_WEEK", "ISO_DAY_OF_WEEK",
+               "WEEKDAY", "DAY_OF_YEAR", "HOUR", "MINUTE", "SECOND")
+_TRANSFORM_INPUTS = {
+    "CONCAT": ("s", 0, None, "STRING"), "CONCAT_WS": ("s", 2, None, "STRING"),
+    "UPPER": ("s", 1, 1, "STRING"), "LOWER": ("s", 1, 1, "STRING"), "TRIM": 
("s", 1, 2, "STRING"),
+    "TRANSLATE": ("s", 3, 3, "STRING"), "SUBSTRING": ("ra", 2, 3, "STRING"),
+    "OVERLAY": ("ssii", 3, 4, "STRING"), "PAD": ("sis", 3, 3, "STRING"),
+    "LENGTH": ("s", 1, 1, "INT"), "BIT_LENGTH": ("s", 1, 1, "INT"),
+    "DATE_ADD": ("di", 2, 2, "DATE"), "DATE_DIFF": ("dd", 2, 2, "INT"), 
"DATE_TRUNC": ("ds", 2, 2, "DATE"),
+}
+_TRANSFORM_FLAGS = {"TRIM": ("trimFlag", ("LEADING", "TRAILING", "BOTH")), 
"PAD": ("direction", ("LEFT", "RIGHT"))}
+_INPUT_ROOTS = {"s": casts.CHARACTER_STRING, "i": casts.INTEGER_NUMERIC, "d": 
{casts.DATE}}
+_TYPE_DEFAULTS = {casts.CHAR: (1,), casts.VARCHAR: (1,), casts.BINARY: (1,), 
casts.VARBINARY: (1,),
+                  casts.DECIMAL: (10, 0), casts.TIME: (0,), casts.TIMESTAMP: 
(6,), casts.TIMESTAMP_LTZ: (6,)}
+_LEAF_FUNCTIONS = {
+    "EQUAL", "NOT_EQUAL", "LESS_THAN", "LESS_OR_EQUAL", "GREATER_THAN", 
"GREATER_OR_EQUAL", "IS_NULL",
+    "IS_NOT_NULL", "STARTS_WITH", "NOT_STARTS_WITH", "ENDS_WITH", 
"NOT_ENDS_WITH", "CONTAINS", "NOT_CONTAINS",
+    "ARRAY_CONTAINS", "ARRAYS_OVERLAP", "ARRAY_CONTAINS_ALL", "LIKE", 
"NOT_LIKE", "IN", "NOT_IN", "BETWEEN",
+    "NOT_BETWEEN", "TRUE", "FALSE"}
+_ARRAY_FUNCTIONS = ("ARRAY_CONTAINS", "ARRAYS_OVERLAP", "ARRAY_CONTAINS_ALL")
+_PRIMITIVES = casts.INTEGER_NUMERIC | {casts.FLOAT, casts.DOUBLE}
+_CAST_RULES = (
+    (casts.CHARACTER_STRING, casts.CHARACTER_STRING | casts.BINARY_STRING | 
_PRIMITIVES | casts.TIMESTAMP_FAMILY
+     | {casts.BOOLEAN, casts.DATE, casts.DECIMAL, casts.TIME, casts.ARRAY, 
casts.MAP, casts.ROW}),
+    (casts.BINARY_STRING, casts.BINARY_STRING | casts.CHARACTER_STRING),
+    ({casts.BOOLEAN}, casts.NUMERIC | casts.CHARACTER_STRING),
+    ({casts.DATE}, casts.CHARACTER_STRING | casts.TIMESTAMP_FAMILY),
+    ({casts.DECIMAL}, _PRIMITIVES | {casts.DECIMAL}),
+    (_PRIMITIVES, _PRIMITIVES | {casts.DECIMAL}),
+    (casts.INTEGER_NUMERIC, {casts.BOOLEAN}),
+    (casts.NUMERIC, casts.CHARACTER_STRING),
+    ({casts.INTEGER, casts.BIGINT}, casts.TIMESTAMP_FAMILY),
+    ({casts.ARRAY, casts.MAP, casts.ROW}, casts.CHARACTER_STRING),
+    (casts.TIMESTAMP_FAMILY, casts.CHARACTER_STRING | casts.TIMESTAMP_FAMILY
+     | {casts.DATE, casts.TIME, casts.BIGINT, casts.INTEGER}),
+    ({casts.TIME}, casts.CHARACTER_STRING | casts.TIMESTAMP_FAMILY),
+)
+_DECIMAL_LITERAL = 
re.compile(r"[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?")
+_LOCAL_DATE = 
re.compile(r"([0-9]{4}|\+[0-9]{5,9}|-[0-9]{4,9})-([0-9]{2})-([0-9]{2})")
+_LOCAL_TIME = 
re.compile(r"([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:\.([0-9]{0,9}))?)?")
+_INTEGER_BITS = {casts.TINYINT: 8, casts.SMALLINT: 16, casts.INTEGER: 32, 
casts.BIGINT: 64}
+
+
+def _type_root(data_type: Optional[DataType]) -> Optional[str]:
+    if isinstance(data_type, AtomicType) and "LOCAL TIME ZONE" in 
data_type.type.upper():
+        return casts.TIMESTAMP_LTZ
+    return casts._root(data_type)
+
+
+def _logical_type(data_type: Optional[DataType]) -> Any:
+    if isinstance(data_type, RowType):
+        return "ROW", tuple((field.id, field.name, _logical_type(field.type), 
field.description, field.default_value)
+                            for field in data_type.fields), data_type.nullable
+    if isinstance(data_type, MapType):
+        return "MAP", _logical_type(data_type.key), 
_logical_type(data_type.value), data_type.nullable
+    if isinstance(data_type, (ArrayType, MultisetType, VectorType)):
+        return type(data_type).__name__, _logical_type(data_type.element), 
getattr(data_type, "length", None), \
+            data_type.nullable
+    if not isinstance(data_type, AtomicType):
+        return data_type
+    spelling = data_type.type.upper()
+    root = _type_root(data_type)
+    found = (2147483647,) if spelling in ("STRING", "BYTES") else 
tuple(map(int, re.findall(r"\d+", spelling)))
+    return root, found + _TYPE_DEFAULTS.get(root, ())[len(found):], 
data_type.nullable
+
+
+def _castable(source: DataType, target: DataType) -> bool:
+    if not any(_type_root(source) in inputs and _type_root(target) in targets 
for inputs, targets in _CAST_RULES):
+        return False
+    if _type_root(source) not in casts.CHARACTER_STRING or not 
isinstance(target, (ArrayType, MapType, RowType)):
+        return True
+    children = [field.type for field in target.fields] if isinstance(target, 
RowType) else \
+        [target.key, target.value] if isinstance(target, MapType) else 
[target.element]
+    return all(_castable(source, child) for child in children)
+
+
+def _java_integer(value: Any, bits: int) -> int:
+    if isinstance(value, bool) or not isinstance(value, (int, float)):
+        raise ValueError("Not a number: {!r}".format(value))
+    if isinstance(value, float):
+        limit = 2 ** (63 if bits == 64 else 31)
+        value = 0 if math.isnan(value) else int(max(-limit, min(limit - 1, 
value)))
+    value &= (1 << bits) - 1
+    return value - (1 << bits) if value >> (bits - 1) else value
+
+
+class _JavaFloat(float):
+    pass
+
+
+def _float32(value: float) -> float:
+    return struct.unpack("f", struct.pack("f", value))[0]
+
+
+def _java_float(value: Any, single: bool) -> float:
+    if isinstance(value, bool) or not isinstance(value, (int, float)):
+        raise ValueError("Not a number: {!r}".format(value))
+    try:
+        value = float(value)
+        return _JavaFloat(_float32(value)) if single else value
+    except OverflowError:
+        return (_JavaFloat if single else float)(math.inf if value > 0 else 
-math.inf)
+
+
+def _java_number_string(value: float, single: bool = False) -> str:
+    if math.isnan(value) or math.isinf(value):
+        return "NaN" if math.isnan(value) else "Infinity" if value > 0 else 
"-Infinity"
+    if value == 0:
+        return "-0.0" if math.copysign(1, value) < 0 else "0.0"
+    fits = (lambda text: _float32(float(text)) == value) if single else 
(lambda text: float(text) == value)
+    digits = next(digits for digits in range(1, 18) if 
fits("{:.{}e}".format(value, digits - 1)))
+    number = decimal.Decimal("{:.{}e}".format(value, max(digits, 2) - 
1)).normalize()
+    if 1e-3 <= abs(value) < 1e7:
+        text = "{:f}".format(number)
+        return text if "." in text else text + ".0"
+    sign, digits, exponent = number.as_tuple()
+    mantissa = "".join(map(str, digits))
+    return "{}{}.{}E{}".format("-" if sign else "", mantissa[0], mantissa[1:] 
or "0", exponent + len(digits) - 1)
+
+
+class _UnsupportedConversion(ValueError):
+    pass
+
+
+def _java_string(value: Any) -> str:
+    if isinstance(value, (float, list, dict)):
+        raise _UnsupportedConversion("Policy literal {} cannot be compared as 
a string.".format(json.dumps(value)))
+    if isinstance(value, _Binary):
+        return value.text
+    if isinstance(value, _Decimal):
+        return "{:f}".format(value.value)
+    if isinstance(value, _Timestamp):
+        return _java_local_date_time(value)
+    if isinstance(value, str):
+        return value
+    if isinstance(value, bool) or value is None:
+        return {True: "true", False: "false", None: "null"}[value]
+    if isinstance(value, float):
+        return _java_number_string(value, isinstance(value, _JavaFloat))
+    if isinstance(value, list):
+        return "[" + ", ".join(map(_java_string, value)) + "]"
+    if isinstance(value, dict):
+        return "{" + ", ".join("{}={}".format(key, _java_string(item)) for 
key, item in value.items()) + "}"
+    return str(value)
+
+
+_Binary = namedtuple("_Binary", "text")
+_Decimal = namedtuple("_Decimal", "value precision")
+_Timestamp = namedtuple("_Timestamp", "millisecond nano_of_millisecond")
+
+
+def _java_decimal(value: Any, literal_type: DataType) -> Optional[_Decimal]:
+    if not isinstance(value, str) or _DECIMAL_LITERAL.fullmatch(value) is None:
+        raise ValueError("Not a decimal: {!r}".format(value))
+    precision, scale = _logical_type(literal_type)[1]
+    with decimal.localcontext() as context:
+        context.prec, context.Emax, context.Emin = 10 ** 6, decimal.MAX_EMAX, 
decimal.MIN_EMIN
+        try:
+            value = 
decimal.Decimal(value).quantize(decimal.Decimal(1).scaleb(-scale), 
rounding=decimal.ROUND_HALF_UP)
+        except decimal.InvalidOperation:
+            return None
+    if len(value.as_tuple().digits) > precision:
+        return None
+    return _Decimal(abs(value) if value.is_zero() else value, precision)
+
+
+def _java_date(year: int, month: int, day: int) -> str:
+    return "{}{:04d}-{:02d}-{:02d}".format("-" if year < 0 else "+" if year > 
9999 else "", abs(year), month, day)
+
+
+def _java_time(hour: int, minute: int, second: int, nanos: int, seconds: bool 
= False) -> str:
+    text = "{:02d}:{:02d}".format(hour, minute)
+    if second or nanos or seconds:
+        text += ":{:02d}".format(second)
+    if nanos:
+        fraction = "{:09d}".format(nanos)
+        text += "." + (fraction[:3] if nanos % 10 ** 6 == 0 else fraction[:6] 
if nanos % 1000 == 0 else fraction)
+    return text
+
+
+def _parse_date(text: str) -> Tuple[int, int, int]:
+    match = _LOCAL_DATE.fullmatch(text)
+    year, month, day = map(int, match.groups()) if match else (0, 0, 0)
+    if not 1 <= month <= 12 or not 1 <= day <= _days_in_month(year, month) or 
(year == 0 and text[0] == "-"):
+        raise ValueError("Not a date: {!r}".format(text))
+    return year, month, day
+
+
+def _parse_time(text: str) -> Tuple[int, int, int, int]:
+    match = _LOCAL_TIME.fullmatch(text)
+    if match is None or int(match.group(1)) > 23 or int(match.group(2)) > 59 
or int(match.group(3) or 0) > 59:
+        raise ValueError("Not a time: {!r}".format(text))
+    return int(match.group(1)), int(match.group(2)), int(match.group(3) or 0), 
int((match.group(4) or "").ljust(9, "0"))
+
+
+def _civil_from_days(days: int) -> Tuple[int, int, int]:
+    days += 719468
+    era = days // 146097
+    day_of_era = days - era * 146097
+    year_of_era = (day_of_era - day_of_era // 1460 + day_of_era // 36524 - 
day_of_era // 146096) // 365
+    day_of_year = day_of_era - (365 * year_of_era + year_of_era // 4 - 
year_of_era // 100)
+    shifted_month = (5 * day_of_year + 2) // 153
+    month = shifted_month + 3 if shifted_month < 10 else shifted_month - 9
+    return year_of_era + era * 400 + (month <= 2), month, day_of_year - (153 * 
shifted_month + 2) // 5 + 1
+
+
+def _timestamp(days: int, hour: int, minute: int, second: int, nanos: int) -> 
_Timestamp:
+    nano_of_day = ((hour * 60 + minute) * 60 + second) * 10 ** 9 + nanos
+    millisecond = days * 86400000 + nano_of_day // 10 ** 6
+    if not -2 ** 63 <= millisecond < 2 ** 63:
+        raise _UnsupportedConversion("Timestamp literal overflows the epoch 
milliseconds Java keeps in a long.")
+    return _Timestamp(millisecond, nano_of_day % 10 ** 6)
+
+
+def _parse_instant(text: str) -> _Timestamp:
+    nanos = _instant_nanos(text)
+    if nanos is None:
+        raise ValueError("Not an instant: {!r}".format(text))
+    sign, digits, month, day, hour, minute, second, _ = 
_INSTANT.fullmatch(text).groups()
+    hour, minute, second = int(hour), int(minute), int(second)
+    next_day = hour == 24
+    hour, second = 0 if next_day else hour, 59 if (hour, minute, second) == 
(23, 59, 60) else second
+    return _timestamp(_days_from_civil(int(sign + digits), int(month), 
int(day)) + next_day, hour, minute, second,
+                      nanos)
+
+
+def _date_time_parts(timestamp: _Timestamp) -> Tuple[str, int, int, int, int]:
+    days, millis = divmod(timestamp.millisecond, 86400000)
+    seconds, nanos = divmod(millis * 10 ** 6 + timestamp.nano_of_millisecond, 
10 ** 9)
+    return _java_date(*_civil_from_days(days)), seconds // 3600, seconds // 60 
% 60, seconds % 60, nanos
+
+
+def _java_local_date_time(timestamp: _Timestamp) -> str:
+    if not -2 ** 31 <= timestamp.millisecond // 86400000 < 2 ** 31:
+        raise _UnsupportedConversion("Timestamp literal overflows the epoch 
days Java keeps in an int.")
+    date, hour, minute, second, nanos = _date_time_parts(timestamp)
+    return date + "T" + _java_time(hour, minute, second, nanos)
+
+
+def _java_instant(timestamp: _Timestamp) -> str:
+    date, hour, minute, second, nanos = _date_time_parts(timestamp)
+    return date + "T" + _java_time(hour, minute, second, nanos, seconds=True) 
+ "Z"
+
+
+def _internal_literal(literal_type: DataType, literal: Any) -> Any:
+    root = _type_root(literal_type)
+    if literal is None or root == casts.BOOLEAN:
+        return literal
+    if root in _INTEGER_BITS:
+        return _java_integer(literal, _INTEGER_BITS[root])
+    if root in (casts.FLOAT, casts.DOUBLE):
+        return _java_float(literal, root == casts.FLOAT)
+    if root in casts.CHARACTER_STRING:
+        return _Binary(_java_string(literal))
+    if not isinstance(literal, str):
+        raise ValueError("{} literal must be a string: {!r}".format(root, 
literal))
+    if root == casts.DECIMAL:
+        return _java_decimal(literal, literal_type)
+    if root == casts.DATE:
+        return _days_from_civil(*_parse_date(literal))
+    if root == casts.TIME:
+        hour, minute, second, nanos = _parse_time(literal)
+        return ((hour * 60 + minute) * 60 + second) * 1000 + nanos // 10 ** 6
+    if root == casts.TIMESTAMP:
+        parts = re.split("[Tt]", literal, maxsplit=1)
+        if len(parts) != 2:
+            raise ValueError("Not a timestamp: {!r}".format(literal))
+        return _timestamp(_days_from_civil(*_parse_date(parts[0])), 
*_parse_time(parts[1]))
+    if root == casts.TIMESTAMP_LTZ:
+        return _parse_instant(literal)
+    raise ValueError("Unsupported literal type {}.".format(literal_type))
+
+
+def _json_literal(literal_type: DataType, value: Any) -> Any:
+    root = _type_root(literal_type)
+    single = isinstance(value, _JavaFloat)
+    if isinstance(value, float) and root != (casts.FLOAT if single else 
casts.DOUBLE) \
+            and (root in _INTEGER_BITS or root in (casts.FLOAT, casts.DOUBLE)) 
\
+            and decimal.Decimal(_java_number_string(value, single)) != 
decimal.Decimal(value):
+        raise _UnsupportedConversion("Policy literal {} cannot be written as 
{} because JDK versions write it "
+                                     
"differently.".format(_java_number_string(value, single), literal_type))
+    if value is None or root == casts.BOOLEAN or root in _INTEGER_BITS or root 
in (casts.FLOAT, casts.DOUBLE):
+        return value
+    if root in casts.CHARACTER_STRING:
+        return _java_string(value)
+    if root in (casts.DATE, casts.TIME):
+        if isinstance(value, bool) or not isinstance(value, (int, float)):
+            raise RuntimeError("{!r} cannot be cast to a 
Number.".format(value))
+        number = _java_integer(value, 32)
+        if root == casts.DATE:
+            return _java_date(*_civil_from_days(number))
+        if not 0 <= number < 86400000:
+            raise RuntimeError("Invalid time of day {}.".format(number))
+        return _java_time(number // 3600000, number // 60000 % 60, number // 
1000 % 60, number % 1000 * 10 ** 6)
+    if root == casts.DECIMAL and isinstance(value, _Decimal):
+        return _java_string(value)
+    if root in (casts.TIMESTAMP, casts.TIMESTAMP_LTZ) and isinstance(value, 
_Timestamp):
+        return _java_local_date_time(value) if root == casts.TIMESTAMP else 
_java_instant(value)
+    raise RuntimeError("{!r} cannot be written as {}.".format(value, 
literal_type))
+
+
+def _literal_type(function: str, output: DataType, error: type = ValueError) 
-> DataType:
+    if function not in _ARRAY_FUNCTIONS:
+        return output
+    if not isinstance(output, ArrayType):
+        raise error("{} needs an ARRAY field.".format(function))
+    return output.element
+
+
+def _leaf_literals(leaf: Dict[str, Any], output: DataType) -> 
Optional[List[Any]]:
+    function, literals = leaf.get("function"), leaf.get("literals")
+    if function not in _LEAF_FUNCTIONS or not isinstance(literals, (list, 
type(None))):
+        raise ValueError("Unknown leaf function {} or literals 
{}.".format(function, literals))
+    literal_type = _literal_type(function, output)
+    return None if literals is None else [_internal_literal(literal_type, 
literal) for literal in literals]
+
+
+def _ref_type(ref: Dict[str, Any], columns: Optional[Dict[str, DataType]]) -> 
DataType:
+    return _declared_type(ref["type"]) if columns is None else 
columns[ref["name"]]
+
+
+def _input_fits(kind: str, value: Any, columns: Optional[Dict[str, DataType]]) 
-> bool:
+    if value is None:
+        return True
+    if isinstance(value, dict):
+        return kind in "ra" or _type_root(_ref_type(value, columns)) in 
_INPUT_ROOTS[kind]
+    if isinstance(value, str):
+        return kind in "sra"
+    if isinstance(value, bool) or not isinstance(value, int):
+        return False
+    return kind in "ia" or (kind == "d" and -2 ** 31 <= value < 2 ** 31)
+
+
+def _transform_type(transform: Dict[str, Any],
+                    columns: Optional[Dict[str, DataType]] = None) -> 
Optional[DataType]:
+    name = transform["name"]
+    if "fieldRef" in transform:
+        source = _ref_type(transform["fieldRef"], columns)
+        if name == "CAST":
+            target = _declared_type(transform["type"])
+            return target if columns is not None or _castable(source, target) 
else None
+        if name in _DATE_PARTS:
+            return AtomicType("INT") if _type_root(source) in (casts.DATE, 
casts.TIMESTAMP) else None
+        for part in transform.get("path") or []:
+            source = next((field.type for field in getattr(source, "fields", 
()) if field.name == part), None)
+        nested = name == "NESTED_FIELD_REF" and transform.get("path")
+        return source if name == "FIELD_REF" or nested else None
+    if name == "NULL":
+        return AtomicType("BOOLEAN")
+    if name in _TRANSFORM_FLAGS:
+        flag, values = _TRANSFORM_FLAGS[name]
+        if transform.get(flag) not in values:
+            return None
+    kinds, low, high, output = _TRANSFORM_INPUTS.get(name, ("", 1, 0, None))
+    inputs = transform.get("inputs")
+    if not isinstance(inputs, list) or not low <= len(inputs) <= (len(inputs) 
if high is None else high):
+        return None
+    fits = all(_input_fits(kinds[min(position, len(kinds) - 1)], value, 
columns)
+               for position, value in enumerate(inputs))
+    return AtomicType(output) if fits else None
+
+
+def _policy_type_error(policy: DataPolicy, schema: TableSchema) -> 
Optional[str]:
+    columns = {field.name: field.type for field in schema.fields}
+    output = None
+    for transform, leaf in _policy_transforms(policy):
+        declared_types = _declared_types(transform)
+        declared = _transform_type(transform)
+        output = _transform_type(transform, columns)
+        if declared is None or output is None:
+            return "Policy transform {} does not fit the table 
schema.".format(transform["name"])
+        if transform["name"] == "NESTED_FIELD_REF":
+            name, path = transform["fieldRef"]["name"], transform["path"]
+            if _path_ids(declared_types[name], path) != 
_path_ids(columns[name], path):
+                return "Nested field {}.{} changed identity.".format(name, 
".".join(path))
+        try:
+            if leaf is not None:
+                _leaf_literals(leaf, declared)
+        except ValueError as e:
+            return "Row filter function {} does not accept {}: {}".format(
+                leaf.get("function"), leaf.get("literals"), e)
+    mask = policy.get_column_mask()
+    if mask is not None and _logical_type(output) != 
_logical_type(columns[mask.get_on_column()]):
+        return "Column mask output type {} does not match target column {} 
type {}.".format(
+            output, mask.get_on_column(), columns[mask.get_on_column()])
+    return None
+
+
+def _java_spelling(value: Any) -> Any:
+    if isinstance(value, dict):
+        return {key: _java_spelling(item) for key, item in value.items() if 
key != "nullable" and item is not None}
+    if isinstance(value, list):
+        return [_java_spelling(item) for item in value]
+    if isinstance(value, str):
+        return re.sub(r"^TIMESTAMP_LTZ\(([0-9]+)\)", r"TIMESTAMP(\1) WITH 
LOCAL TIME ZONE", value)
+    return value
+
+
+_TYPE_RANGES = {casts.CHAR: ((1, 2147483647),), casts.VARCHAR: ((1, 
2147483647),),
+                casts.BINARY: ((1, 2147483647),), casts.VARBINARY: ((1, 
2147483647),), casts.DECIMAL: ((1, 38),),
+                casts.TIME: ((0, 9),), casts.TIMESTAMP: ((0, 9),), 
casts.TIMESTAMP_LTZ: ((0, 9),)}
+
+
+def _valid_type(data_type: DataType) -> bool:
+    if not isinstance(data_type, AtomicType):
+        children = [field.type for field in getattr(data_type, "fields", ())] 
+ [
+            getattr(data_type, name) for name in ("element", "key", "value") 
if hasattr(data_type, name)]
+        return all(map(_valid_type, children))
+    root, parameters, _ = _logical_type(data_type)
+    ranges = _TYPE_RANGES.get(root, ())
+    if root == casts.DECIMAL and len(parameters) == 2 and not 0 <= 
parameters[1] <= parameters[0]:
+        return False
+    return all(low <= value <= high for value, (low, high) in zip(parameters, 
ranges))
+
+
+def _declared_type(value: Any) -> DataType:
+    data_type = DataTypeParser.parse_data_type(value)
+    if _java_spelling(value) != _java_type(data_type) or not 
_valid_type(data_type):
+        raise ValueError("Policy type {} is not written as Java writes 
it.".format(json.dumps(value)))
+    return data_type
+
+
+def _declared_types(transform: Dict[str, Any]) -> Dict[str, DataType]:
+    return {ref["name"]: _declared_type(ref["type"]) for ref in 
_field_refs(transform)}
+
+
+def _canonical_transform(transform: Dict[str, Any], schema: TableSchema) -> 
Dict[str, Any]:
+    names = [field.name for field in schema.fields]
+
+    def remap(value: Any) -> Any:
+        if not isinstance(value, dict):
+            return value
+        index = names.index(value["name"])
+        return {"index": index, "name": value["name"], "type": 
_java_type(schema.fields[index].type)}
+
+    canonical = {"name": transform["name"]}
+    if "fieldRef" in transform:
+        canonical["fieldRef"] = remap(transform["fieldRef"])
+        if transform["name"] == "NESTED_FIELD_REF":
+            canonical["path"] = transform["path"]
+        if transform["name"] == "CAST":
+            canonical["type"] = _java_type(_declared_type(transform["type"]))
+    elif transform["name"] != "NULL":
+        canonical["inputs"] = [remap(value) for value in 
transform.get("inputs") or []]
+    if transform["name"] in _TRANSFORM_FLAGS:
+        flag = _TRANSFORM_FLAGS[transform["name"]][0]
+        canonical[flag] = transform[flag]
+    return canonical
+
+
+def _canonical_predicate(predicate: Dict[str, Any], schema: TableSchema) -> 
Dict[str, Any]:
+    if predicate["kind"] == "COMPOUND":
+        children = [_canonical_predicate(child, schema) for child in 
predicate["children"]]
+        return children[0] if len(children) == 1 else {
+            "kind": "COMPOUND", "function": predicate["function"], "children": 
children}
+    transform = predicate["transform"]
+    literals = _leaf_literals(predicate, _transform_type(transform))
+    output = _transform_type(transform, {field.name: field.type for field in 
schema.fields})
+    literal_type = _literal_type(predicate["function"], output, RuntimeError)
+    return {"kind": "LEAF", "transform": _canonical_transform(transform, 
schema), "function": predicate["function"],
+            "literals": None if literals is None else 
[_json_literal(literal_type, value) for value in literals]}
+
+
+def _canonical_policy(policy: DataPolicy, schema: TableSchema) -> DataPolicy:
+    mask = policy.get_column_mask()
+    if mask is None:
+        predicate = 
_canonical_predicate(_policy_json(policy.get_row_filter().get_predicate()), 
schema)
+        return DataPolicy.row_filter(policy.get_resource(), 
RowFilter(_flat_json(predicate)), policy.get_principal())
+    transform = _canonical_transform(_policy_json(mask.get_transform()), 
schema)
+    return DataPolicy.column_mask(policy.get_resource(), 
ColumnMask(mask.get_on_column(), _flat_json(transform)),
+                                  policy.get_principal())
+
+
+def _flat_json(value: Any) -> str:
+    if isinstance(value, _Binary):
+        data = value.text.encode("utf-8")
+        encoded = base64.b64encode(data).decode("ascii")
+        value = {"segments": [{"heapMemory": encoded, "array": encoded, 
"offHeap": False}],
+                 "offset": 0, "sizeInBytes": len(data)}
+    elif isinstance(value, _Decimal):
+        value = {"compact": value.precision <= 18}
+    elif isinstance(value, _Timestamp):
+        value = {"millisecond": value.millisecond, "nanoOfMillisecond": 
value.nano_of_millisecond}
+    if isinstance(value, dict):
+        return "{" + ",".join(_flat_json(key) + ":" + _flat_json(item) for 
key, item in value.items()) + "}"
+    if isinstance(value, list):
+        return "[" + ",".join(map(_flat_json, value)) + "]"
+    if isinstance(value, float):
+        text = _java_number_string(value, isinstance(value, _JavaFloat))
+        return json.dumps(text) if math.isnan(value) or math.isinf(value) else 
text
+    return json.dumps(value, ensure_ascii=False)
+
+
+_JAVA_TYPE_FORMATS = {
+    casts.CHAR: "CHAR({})", casts.VARCHAR: "VARCHAR({})", casts.BINARY: 
"BINARY({})",
+    casts.VARBINARY: "VARBINARY({})", casts.INTEGER: "INT", casts.DECIMAL: 
"DECIMAL({}, {})",
+    casts.TIME: "TIME({})", casts.TIMESTAMP: "TIMESTAMP({})", 
casts.TIMESTAMP_LTZ: "TIMESTAMP({}) WITH LOCAL TIME ZONE"}
+
+
+def _java_type(data_type: DataType) -> Any:
+    suffix = "" if data_type.nullable else " NOT NULL"
+    if isinstance(data_type, AtomicType):
+        root, parameters, _ = _logical_type(data_type)
+        if parameters == (2147483647,) and root in (casts.VARCHAR, 
casts.VARBINARY):
+            return ("STRING" if root == casts.VARCHAR else "BYTES") + suffix
+        return _JAVA_TYPE_FORMATS.get(root, root).format(*parameters) + suffix
+    if isinstance(data_type, RowType):
+        return {"type": "ROW" + suffix, "fields": [_java_field(field) for 
field in data_type.fields]}
+    if isinstance(data_type, MapType):
+        return {"type": "MAP" + suffix, "key": _java_type(data_type.key), 
"value": _java_type(data_type.value)}
+    name = {ArrayType: "ARRAY", MultisetType: "MULTISET", VectorType: 
"VECTOR"}[type(data_type)]
+    java = {"type": name + suffix, "element": _java_type(data_type.element)}
+    if isinstance(data_type, VectorType):
+        java["length"] = data_type.length
+    return java
+
+
+def _java_field(field: DataField) -> Dict[str, Any]:
+    java = {"id": field.id, "name": field.name, "type": _java_type(field.type)}
+    if field.description is not None:
+        java["description"] = field.description
+    if field.default_value is not None:
+        java["defaultValue"] = field.default_value
+    return java
+
+
+def _path_ids(data_type: Optional[DataType], path: List[str]) -> 
Optional[List[int]]:
+    ids = []
+    for part in path:
+        field = next((field for field in getattr(data_type, "fields", ()) if 
field.name == part), None)
+        if field is None:
+            return None
+        ids.append(field.id)
+        data_type = field.type
+    return ids
+
+
+def _moved_columns(policy: DataPolicy, previous: TableSchema, current: 
TableSchema) -> List[str]:
+    previous_ids = {field.name: field.id for field in previous.fields}
+    current_ids = {field.name: field.id for field in current.fields}
+    return sorted(column for column in _policy_columns(policy) if 
current_ids.get(column) != previous_ids.get(column))
+
+
 def _granted_columns(assignment: PermissionAssignment) -> List[str]:
     columns = assignment.get_columns()
     names = columns.get_column_names()
@@ -267,6 +875,14 @@ def _matches(parameters: Dict[str, str], name: str, value: 
Optional[str]) -> boo
     return name not in parameters or parameters[name] == value
 
 
+def _matches_policy(policy: DataPolicy, parameters: Dict[str, str]) -> bool:
+    mask = policy.get_column_mask()
+    if "column" in parameters and (mask is None or mask.get_on_column() != 
parameters["column"]):
+        return False
+    return _matches(parameters, "type", policy.type().name) \
+        and _matches(parameters, "principal", policy.get_principal())
+
+
 class RESTCatalogServer:
     """Mock REST server for testing"""
 
@@ -296,7 +912,11 @@ class RESTCatalogServer:
         self.table_token_store: Dict[str, "RESTToken"] = {}
         self.table_query_auth_store = {}
         self.permission_store: Dict[str, PermissionAssignment] = {}
+        self.policy_store: Dict[str, Dict[str, DataPolicy]] = {}
+        self.table_identifiers: Dict[str, Identifier] = {}
         self.query_principals: set = set()
+        self.management_principals: set = set()
+        self.no_management_permission_resources: set = set()
 
         # Initialize mock catalog (simplified)
         self.data_path = data_path
@@ -490,6 +1110,12 @@ class RESTCatalogServer:
                 path_parts = resource_path[len(self.database_uri) + 
1:].split('/')
                 database_name = RESTUtil.decode_string(path_parts[0])
 
+                if len(path_parts) in (4, 5) and path_parts[1] == 
ResourcePaths.TABLES \
+                        and path_parts[3] == ResourcePaths.POLICIES and 
path_parts[4:] in ([], ['drop']):
+                    return self._policies_handle(
+                        method, data, Identifier.create(database_name, 
RESTUtil.decode_string(path_parts[2])),
+                        parameters, len(path_parts) == 5)
+
                 # Check database permissions
                 if database_name in self.no_permission_databases:
                     raise DatabaseNoPermissionException(database_name)
@@ -638,6 +1264,8 @@ class RESTCatalogServer:
             elif operation == "token":
                 return self._table_token_handle(method, lookup_identifier)
             elif operation == "auth":
+                if branch_part is not None:
+                    self._branch_table_metadata(lookup_identifier, branch_part)
                 return self._table_query_auth_handle(method, data, 
lookup_identifier)
             elif operation == "rollback":
                 return self._table_rollback_handle(method, data, 
lookup_identifier)
@@ -669,15 +1297,35 @@ class RESTCatalogServer:
     def set_query_principals(self, *principals: str) -> None:
         self.query_principals = set(principals)
 
+    def register_management_principal(self, *principals: str) -> None:
+        self.management_principals.update(principals)
+
+    def deny_management_permission(self, resource: PermissionResource) -> None:
+        self.no_management_permission_resources.add(resource)
+
+    def _denied_management(self, resource: PermissionResource) -> 
Optional[Tuple[str, int]]:
+        if resource not in self.no_management_permission_resources:
+            return None
+        return self._mock_response(ErrorResponse(
+            resource.get_type().name, _resource_name(resource),
+            "The caller cannot manage permissions on this resource.", 403), 
403)
+
+    def _missing_principal(self, principal: str) -> Optional[Tuple[str, int]]:
+        if principal in self.management_principals:
+            return None
+        return self._mock_response(ErrorResponse(
+            "PRINCIPAL", principal, "Permission principal does not exist.", 
404), 404)
+
     def _permissions_handle(self, method: str, resource_path: str, data: str,
                             parameters: Dict[str, str]) -> Tuple[str, int]:
         if method == "GET" and resource_path == 
self.resource_paths.permissions():
             target = PermissionResource(
                 parameters.get("resourceType"), parameters.get("database"),
                 parameters.get("table"), parameters.get("function"), 
parameters.get("view"))
-            missing = self._missing_permission_resource(target)
-            if missing is not None:
-                return missing
+            error = self._denied_management(target) or 
self._missing_permission_resource(target) \
+                or (self._missing_principal(parameters["principal"]) if 
"principal" in parameters else None)
+            if error is not None:
+                return error
             matched = [assignment for assignment in 
self.permission_store.values()
                        if assignment.get_resource() == target
                        and _matches(parameters, "principal", 
assignment.get_principal())
@@ -686,19 +1334,20 @@ class RESTCatalogServer:
                 *self._management_page({_assignment_key(grant): grant for 
grant in matched}, parameters)), 200)
         if method == "POST" and resource_path == 
self.resource_paths.grant_permission():
             assignment = JSON.from_json(data, 
GrantPermissionRequest).assignment()
-            missing = 
self._missing_permission_resource(assignment.get_resource())
-            if missing is not None:
-                return missing
-            invalid = self._invalid_column_grant(assignment)
-            if invalid is not None:
-                return invalid
+            error = self._denied_management(assignment.get_resource()) \
+                or 
self._missing_permission_resource(assignment.get_resource()) \
+                or self._missing_principal(assignment.get_principal()) or 
self._invalid_column_grant(assignment)
+            if error is not None:
+                return error
             self.permission_store[_assignment_key(assignment)] = assignment
             return self._mock_response("", 200)
         if method == "POST" and resource_path == 
self.resource_paths.revoke_permission():
             request = JSON.from_json(data, RevokePermissionRequest)
-            missing = self._missing_permission_resource(request.get_resource())
-            if missing is not None:
-                return missing
+            error = self._denied_management(request.get_resource()) \
+                or self._missing_permission_resource(request.get_resource()) \
+                or self._missing_principal(request.get_principal())
+            if error is not None:
+                return error
             self.permission_store.pop(_permission_key(
                 request.get_resource(), request.get_access(), 
request.get_principal()), None)
             return self._mock_response("", 200)
@@ -747,26 +1396,102 @@ class RESTCatalogServer:
                     "Permission column does not exist.", 404), 404)
         return None
 
-    def _restricted(self, identifier: Identifier) -> bool:
-        return bool(self._column_grants(identifier))
+    def _restricted(self, identifier: Identifier, metadata: TableMetadata) -> 
bool:
+        return bool(self.policy_store.get(metadata.uuid or 
identifier.get_full_name())
+                    or self._column_grants(identifier))
 
     def _column_grants(self, identifier: Identifier) -> 
List[PermissionAssignment]:
         return [assignment for assignment in self.permission_store.values()
                 if assignment.get_resource().get_type() == ResourceType.COLUMN
                 and _on_table(assignment.get_resource(), identifier)]
 
+    def _policies_handle(self, method: str, data: str, identifier: Identifier,
+                         parameters: Dict[str, str], drop: bool) -> Tuple[str, 
int]:
+        resource = PermissionResource(
+            ResourceType.TABLE, identifier.get_database_name(), 
identifier.get_object_name())
+        denied = self._denied_management(resource)
+        if denied is not None:
+            return denied
+        metadata = self.table_metadata_store.get(identifier.get_full_name())
+        if metadata is None:
+            return self._missing_permission_resource(resource)
+        policies = self._table_policies(identifier, metadata)
+        if method == "GET" and not drop:
+            unknown = self._missing_principal(parameters["principal"]) if 
"principal" in parameters else None
+            if unknown is not None:
+                return unknown
+            matched = [DataPolicy(resource, policy.get_row_filter(), 
policy.get_column_mask(),
+                                  policy.get_principal())
+                       for policy in policies.values() if 
_matches_policy(policy, parameters)]
+            return self._mock_response(ListPoliciesResponse(
+                *self._management_page({_policy_key(policy): policy for policy 
in matched}, parameters)), 200)
+        if method == "POST" and not drop:
+            policy = JSON.from_json(data, PolicyRequest).policy(resource)
+            if self.branch_store.get(identifier.get_full_name()):
+                return self._mock_response(ErrorResponse(
+                    ErrorResponse.RESOURCE_TYPE_TABLE, 
identifier.get_full_name(),
+                    "Data policies are not supported on tables with 
branches.", 400), 400)
+            if not _query_auth_enabled(metadata.schema):
+                return self._mock_response(ErrorResponse(
+                    ErrorResponse.RESOURCE_TYPE_TABLE, 
identifier.get_full_name(),
+                    "Data policies require the target table option 
query-auth.enabled=true.",
+                    409), 409)
+            unknown = self._missing_principal(policy.get_principal())
+            if unknown is not None:
+                return unknown
+            identity = _policy_identity(policy)
+            try:
+                missing = sorted(_policy_columns(policy) - {field.name for 
field in metadata.schema.fields})
+                error = "Policy column {} does not exist in table {}.".format(
+                    missing[0], identifier.get_full_name()) if missing else 
_policy_type_error(policy, metadata.schema)
+            except Exception as e:
+                error = "Invalid policy: {}".format(e)
+            if error:
+                return self._mock_response(ErrorResponse(
+                    ErrorResponse.RESOURCE_TYPE_POLICY, 
_policy_name(*identity), error, 400), 400)
+            try:
+                canonical = _canonical_policy(policy, metadata.schema)
+            except _UnsupportedConversion as e:
+                return self._mock_response(ErrorResponse(
+                    ErrorResponse.RESOURCE_TYPE_POLICY, 
_policy_name(*identity), str(e), 400), 400)
+            if _cursor_key(*identity) in policies:
+                return self._mock_response(ErrorResponse(
+                    ErrorResponse.RESOURCE_TYPE_POLICY, 
_policy_name(*identity),
+                    "Policy already exists.", 409), 409)
+            policies[_cursor_key(*identity)] = canonical
+            return self._mock_response("", 200)
+        if method == "POST" and drop:
+            request = JSON.from_json(data, DropPolicyRequest)
+            identity = (request.get_type().name, request.get_principal(), 
request.get_column())
+            if policies.pop(_cursor_key(*identity), None) is None:
+                return self._mock_response(ErrorResponse(
+                    ErrorResponse.RESOURCE_TYPE_POLICY, 
_policy_name(*identity),
+                    "Policy does not exist.", 404), 404)
+            return self._mock_response("", 200)
+        return self._mock_response(ErrorResponse(None, None, "Not Found", 
404), 404)
+
     def _remove_permissions(self, matches) -> None:
         for key in [key for key, assignment in self.permission_store.items()
                     if matches(assignment.get_resource())]:
             del self.permission_store[key]
 
-    def _drop_table_access(self, identifier: Identifier) -> None:
+    def _drop_table_access(self, identifier: Identifier, metadata: 
TableMetadata) -> None:
         for branch in self.branch_store.pop(identifier.get_full_name(), ()):
             manager = self._schema_manager(identifier, branch)
             manager.file_io.delete(manager.branch_path, recursive=True)
+        if not metadata.is_external:
+            manager = self._schema_manager(identifier)
+            manager.file_io.delete(manager.table_path, recursive=True)
+        self.policy_store.pop(metadata.uuid or identifier.get_full_name(), 
None)
+        self.table_identifiers.pop(identifier.get_full_name(), None)
         self._remove_permissions(lambda resource: _on_table(resource, 
identifier))
 
+    def _table_identifier(self, name: str) -> Identifier:
+        return self.table_identifiers.get(name) or Identifier.from_string(name)
+
     def _rename_table_access(self, source: Identifier, destination: 
Identifier) -> None:
+        self.table_identifiers.pop(source.get_full_name(), None)
+        self.table_identifiers[destination.get_full_name()] = destination
         moved = [assignment for assignment in self.permission_store.values()
                  if _on_table(assignment.get_resource(), source)]
         self._remove_permissions(lambda resource: _on_table(resource, source))
@@ -777,6 +1502,15 @@ class RESTCatalogServer:
                 assignment.get_access(), assignment.get_principal(),
                 assignment.get_columns(), assignment.get_expire_time())
             self.permission_store[_assignment_key(renamed)] = renamed
+        metadata = self.table_metadata_store[destination.get_full_name()]
+        source_key = metadata.uuid or source.get_full_name()
+        if source_key in self.policy_store:
+            policies = self.policy_store.pop(source_key)
+            self._table_policies(destination, metadata).update(policies)
+
+    def _table_policies(self, identifier: Identifier,
+                        metadata: TableMetadata) -> Dict[str, DataPolicy]:
+        return self.policy_store.setdefault(metadata.uuid or 
identifier.get_full_name(), {})
 
     def _check_column_grants(self, identifier: Identifier, select: 
Optional[List[str]]) -> None:
         fields = [field.name for field in 
self.table_metadata_store[identifier.get_full_name()].schema.fields]
@@ -793,6 +1527,29 @@ class RESTCatalogServer:
             if any(column not in included for column in (fields if select is 
None else select)):
                 raise TableNoPermissionException(identifier)
 
+    def _policy_rules(self, identifier: Identifier) -> 
Tuple[Optional[List[str]], Optional[Dict[str, str]]]:
+        metadata = self.table_metadata_store[identifier.get_full_name()]
+        policies = self._table_policies(identifier, metadata)
+        names = {field.name for field in metadata.schema.fields}
+        applied, masked = [], set()
+        for key in sorted(policies):
+            policy = policies[key]
+            if policy.get_principal() not in self.query_principals:
+                continue
+            try:
+                invalid = _policy_columns(policy) - names or 
_policy_type_error(policy, metadata.schema)
+            except Exception:
+                invalid = True
+            mask = policy.get_column_mask()
+            if invalid or (mask is not None and mask.get_on_column() in 
masked):
+                raise TableNoPermissionException(identifier)
+            masked |= {mask.get_on_column()} if mask is not None else set()
+            applied.append(_canonical_policy(policy, metadata.schema))
+        filters = [policy.get_row_filter().get_predicate() for policy in 
applied if policy.get_row_filter()]
+        masks = {policy.get_column_mask().get_on_column(): 
policy.get_column_mask().get_transform()
+                 for policy in applied if policy.get_column_mask()}
+        return filters or None, masks or None
+
     # ======================= Function Handlers ===============================
 
     def _functions_handle(self, method: str, data: str, database_name: str,
@@ -1124,8 +1881,8 @@ class RESTCatalogServer:
                     raise TagNotExistException(request.from_tag)
                 snapshot = tags[request.from_tag].snapshot
                 schema_id = schema_id if snapshot is None else 
snapshot.schema_id
-            if self._restricted(identifier):
-                raise ValueError("Tables with column permissions cannot have 
branches.")
+            if self._restricted(identifier, metadata):
+                raise ValueError("Tables with data policies or column 
permissions cannot have branches.")
             manager = self._schema_manager(identifier)
             if snapshot is not None:
                 self._write_snapshot_files(identifier, snapshot, None, 
request.branch)
@@ -1219,6 +1976,11 @@ class RESTCatalogServer:
 
         elif method == "DELETE":
             del self.database_store[database_name]
+            for name in [name for name in self.table_metadata_store
+                         if self._table_identifier(name).get_database_name() 
== database_name]:
+                self._drop_table_access(self._table_identifier(name), 
self.table_metadata_store.pop(name))
+                self.table_latest_snapshot_store.pop(name, None)
+                self.table_partitions_store.pop(name, None)
             self._remove_permissions(lambda resource: resource.get_database() 
== database_name)
             return self._mock_response("", 200)
         return self._mock_response(ErrorResponse(None, None, "Method Not 
Allowed", 405), 405)
@@ -1242,6 +2004,7 @@ class RESTCatalogServer:
                     create_table.identifier, 0, create_table.schema, 
str(uuid.uuid4()), False
                 )
                 
self.table_metadata_store.update({create_table.identifier.get_full_name(): 
table_metadata})
+                
self.table_identifiers[create_table.identifier.get_full_name()] = 
create_table.identifier
                 table_dir = (
                     Path(self.data_path) / self.warehouse / database_name /
                     create_table.identifier.get_object_name() / 'schema'
@@ -1274,11 +2037,12 @@ class RESTCatalogServer:
             return self._mock_response("", 200)
         elif method == "DELETE":
             # Drop table
-            if identifier.get_full_name() not in self.table_metadata_store:
-                raise TableNotExistException(identifier)
+            if identifier.get_full_name() not in self.table_metadata_store \
+                    or (response_identifier or identifier).get_branch_name() 
is not None:
+                raise TableNotExistException(response_identifier or identifier)
             else:
-                del self.table_metadata_store[identifier.get_full_name()]
-                self._drop_table_access(identifier)
+                self._drop_table_access(
+                    identifier, 
self.table_metadata_store.pop(identifier.get_full_name()))
             if identifier.get_full_name() in self.table_latest_snapshot_store:
                 del 
self.table_latest_snapshot_store[identifier.get_full_name()]
             if identifier.get_full_name() in self.table_partitions_store:
@@ -1296,8 +2060,10 @@ class RESTCatalogServer:
         select = json.loads(data).get('select')
         self._check_column_grants(identifier, select)
         from pypaimon.api.api_response import AuthTableQueryResponse
-        response = self.table_query_auth_store.get(identifier.get_full_name(), 
AuthTableQueryResponse())
-        if callable(response):
+        response = self.table_query_auth_store.get(identifier.get_full_name())
+        if response is None:
+            response = AuthTableQueryResponse(*self._policy_rules(identifier))
+        elif callable(response):
             response = response(select)
         return self._mock_response(response, 200)
 
@@ -1633,7 +2399,8 @@ class RESTCatalogServer:
                          parameters: Dict[str, str]) -> Tuple[List[Any], 
Optional[str]]:
         token = parameters.get(PAGE_TOKEN)
         keys = sorted(key for key in entities if not token or key > token)
-        page = keys[:self._get_max_results(parameters)]
+        size = int(parameters[MAX_RESULTS]) if parameters.get(MAX_RESULTS) 
else DEFAULT_MAX_RESULTS
+        page = keys[:max(1, min(size, ListPermissionsRequest.MAX_PAGE_SIZE))]
         return [entities[key] for key in page], page[-1] if len(keys) > 
len(page) else None
 
     def _build_paged_entities(self, entities: List[Any], max_results: int,
@@ -1753,6 +2520,21 @@ class RESTCatalogServer:
     def _validate_access_for_schema(self, identifier: Identifier, metadata: 
TableMetadata,
                                     schema: TableSchema) -> None:
         name = identifier.get_full_name()
+        policies = self.policy_store.get(metadata.uuid or name, {})
+        if policies and not _query_auth_enabled(schema):
+            raise ValueError(
+                "Cannot disable query-auth.enabled while table {} has data 
policies.".format(name))
+        for policy in policies.values():
+            missing = _moved_columns(policy, metadata.schema, schema)
+            if missing:
+                raise ValueError("Cannot remove or rename policy column {} 
from table {}.".format(missing[0], name))
+            mismatch = _policy_type_error(policy, schema)
+            if mismatch:
+                raise ValueError(mismatch)
+            try:
+                _canonical_policy(policy, schema)
+            except Exception as e:
+                raise ValueError(str(e)) from e
         grants = self._column_grants(identifier)
         if grants and not _query_auth_enabled(schema):
             raise ValueError(
@@ -1826,7 +2608,7 @@ class RESTCatalogServer:
         tables = []
 
         for full_name, metadata in self.table_metadata_store.items():
-            identifier = Identifier.from_string(full_name)
+            identifier = self._table_identifier(full_name)
             metadata_table_type = (
                 metadata.schema.options.get(TYPE, "table")
                 if metadata and metadata.schema and metadata.schema.options
diff --git a/paimon-python/pypaimon/tests/sql_permission_test.py 
b/paimon-python/pypaimon/tests/sql_permission_test.py
index 158f17f72a..ae5655bdcc 100644
--- a/paimon-python/pypaimon/tests/sql_permission_test.py
+++ b/paimon-python/pypaimon/tests/sql_permission_test.py
@@ -15,6 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import json
+
 import pyarrow as pa
 import pytest
 
@@ -22,11 +24,17 @@ from pypaimon import CatalogFactory, Schema
 from pypaimon.api.api_response import ConfigResponse
 from pypaimon.api.auth import BearTokenAuthProvider
 from pypaimon.catalog.catalog_exception import TableNoPermissionException
+from pypaimon.management.column_mask import ColumnMask
+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_columns import PermissionColumns
 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
+from pypaimon.management.row_filter import RowFilter
 from pypaimon.read.native_plan import native_runtime_available
 from pypaimon.schema.schema_change import SchemaChange
 from pypaimon.tests.rest.rest_server import RESTCatalogServer
@@ -46,10 +54,19 @@ pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
 TOKEN = 'test-token'
 ANALYST = 'analyst'
 ROWS = [
-    {'id': 1, 'region': 'eu', 'email': '[email protected]'},
-    {'id': 2, 'region': 'us', 'email': '[email protected]'},
-    {'id': 3, 'region': 'eu', 'email': '[email protected]'},
+    {'id': 1, 'region': 'eu', 'email': '[email protected]', 'vip': True},
+    {'id': 2, 'region': 'us', 'email': '[email protected]', 'vip': False},
+    {'id': 3, 'region': 'eu', 'email': '[email protected]', 'vip': None},
 ]
+EU_ONLY = json.dumps({
+    'kind': 'LEAF',
+    'transform': {'name': 'FIELD_REF',
+                  'fieldRef': {'index': 1, 'name': 'region', 'type': 
'STRING'}},
+    'function': 'EQUAL',
+    'literals': ['eu'],
+}, separators=(',', ':'))
+NULL_MASK = json.dumps({'name': 'NULL'}, separators=(',', ':'))
+STARS_MASK = json.dumps({'name': 'CONCAT', 'inputs': ['***']}, 
separators=(',', ':'))
 ORDERS = PermissionResource(ResourceType.TABLE, 'default', 'orders')
 
 
@@ -60,6 +77,7 @@ def clients(tmp_path):
     server = RESTCatalogServer(str(tmp_path), BearTokenAuthProvider(TOKEN),
                                ConfigResponse(defaults={'prefix': 
'permission-test'}),
                                'warehouse')
+    server.register_management_principal(ANALYST)
     server.start()
     try:
         options = {'metastore': 'rest', 'uri': server.get_url(), 'warehouse': 
'warehouse',
@@ -74,7 +92,7 @@ def clients(tmp_path):
 
 
 def _create_orders(catalog, rows=()):
-    schema = pa.schema([('id', pa.int64()), ('region', pa.string()), ('email', 
pa.string())])
+    schema = pa.schema([('id', pa.int64()), ('region', pa.string()), ('email', 
pa.string()), ('vip', pa.bool_())])
     catalog.create_table('default.orders', Schema.from_pyarrow_schema(schema), 
False)
     if rows:
         table = catalog.get_table('default.orders')
@@ -97,14 +115,14 @@ def _call_ok(context, sql):
     assert _sql(context, sql) == [{'result': 'OK'}], sql
 
 
-def _python_rows(catalog, columns=('id', 'region', 'email')):
+def _python_rows(catalog, columns=('id', 'region', 'email', 'vip')):
     table = catalog.get_table('default.orders')
     read_builder = table.new_read_builder().with_projection(list(columns))
     splits = read_builder.new_scan().plan().splits()
     return sorted(read_builder.new_read().to_arrow(splits).to_pylist(), 
key=lambda row: row['id'])
 
 
-def _sql_rows(context, columns=('id', 'region', 'email')):
+def _sql_rows(context, columns=('id', 'region', 'email', 'vip')):
     return _sql(context, 'SELECT {} FROM paimon.default.orders ORDER BY 
id'.format(', '.join(columns)))
 
 
@@ -159,3 +177,83 @@ def test_column_grants_restrict_both_readers(clients):
         _python_rows(catalog)
     with pytest.raises(Exception, match='(?i)permission'):
         _sql_rows(context)
+
+
+def _policies(catalog):
+    page = 
catalog.policy_management().list_policies(ListPoliciesRequest(ORDERS))
+    return sorted((policy.type().name, policy.get_principal(),
+                   policy.get_row_filter() and 
policy.get_row_filter().get_predicate(),
+                   policy.get_column_mask() and 
policy.get_column_mask().get_on_column(),
+                   policy.get_column_mask() and 
policy.get_column_mask().get_transform())
+                  for policy in page.elements)
+
+
+def test_sql_and_pypaimon_manage_the_same_policies(clients):
+    catalog, context, _ = clients
+    _create_orders(catalog)
+    policies = catalog.policy_management()
+
+    _call_ok(context, "CALL sys.create_policy(database => 'default', table => 
'orders', "
+                      "policy_type => 'ROW_FILTER', principal => 'analyst', "
+                      "predicate_json => '{}')".format(EU_ONLY))
+    policies.create_policy(DataPolicy.column_mask(ORDERS, ColumnMask('vip', 
NULL_MASK), ANALYST))
+    assert _policies(catalog) == [
+        ('COLUMN_MASKING', ANALYST, None, 'vip', NULL_MASK),
+        ('ROW_FILTER', ANALYST, EU_ONLY, None, None),
+    ]
+    assert sorted((row['policy_type'], row['on_column'], 
row['predicate_json'], row['transform_json'])
+                  for row in _sql(context, "CALL sys.list_policies(database => 
'default', "
+                                           "table => 'orders', principal => 
'analyst')")) == [
+        ('COLUMN_MASKING', 'vip', None, NULL_MASK),
+        ('ROW_FILTER', None, EU_ONLY, None),
+    ]
+
+    with pytest.raises(PolicyAlreadyExistException):
+        policies.create_policy(DataPolicy.row_filter(ORDERS, 
RowFilter(EU_ONLY), ANALYST))
+    with pytest.raises(Exception, match='already exists'):
+        context.sql("CALL sys.create_policy(database => 'default', table => 
'orders', "
+                    "policy_type => 'COLUMN_MASKING', principal => 'analyst', "
+                    "on_column => 'vip', transform_json => 
'{}')".format(NULL_MASK))
+
+    _call_ok(context, "CALL sys.drop_policy(database => 'default', table => 
'orders', "
+                      "policy_type => 'COLUMN_MASKING', principal => 
'analyst', column => 'vip')")
+    policies.drop_policy(ORDERS, PolicyType.ROW_FILTER, ANALYST, None, False)
+    assert _policies(catalog) == []
+    _call_ok(context, "CALL sys.drop_policy(database => 'default', table => 
'orders', "
+                      "policy_type => 'ROW_FILTER', principal => 'analyst', 
if_exists => 'true')")
+
+
[email protected](RUST_VERSION < (0, 5), reason='pypaimon-rust 0.5 or later 
required')
+def test_row_filter_restricts_both_readers_alike(clients):
+    catalog, context, server = clients
+    _create_orders(catalog, ROWS)
+    _call_ok(context, "CALL sys.create_policy(database => 'default', table => 
'orders', "
+                      "policy_type => 'ROW_FILTER', principal => 'analyst', "
+                      "predicate_json => '{}')".format(EU_ONLY))
+
+    assert _python_rows(catalog) == ROWS
+    assert _sql_rows(context) == ROWS
+
+    server.set_query_principals(ANALYST)
+    eu_rows = [row for row in ROWS if row['region'] == 'eu']
+    assert _python_rows(catalog) == eu_rows
+    assert _sql_rows(context) == eu_rows
+    assert _sql(context, 'SELECT count(*) AS n FROM paimon.default.orders') == 
[{'n': 2}]
+
+
[email protected](RUST_VERSION < (0, 5), reason='pypaimon-rust 0.5 or later 
required')
+def test_column_masks_mask_both_readers_alike(clients):
+    catalog, context, server = clients
+    _create_orders(catalog, ROWS)
+    policies = catalog.policy_management()
+    policies.create_policy(DataPolicy.column_mask(ORDERS, ColumnMask('vip', 
NULL_MASK), ANALYST))
+    policies.create_policy(DataPolicy.column_mask(ORDERS, ColumnMask('region', 
STARS_MASK), ANALYST))
+
+    server.set_query_principals(ANALYST)
+    masked = [dict(row, region='***', vip=None) for row in ROWS]
+    assert _python_rows(catalog) == masked
+    assert _sql_rows(context) == masked
+
+    server.set_query_principals('someone-else')
+    assert _python_rows(catalog) == ROWS
+    assert _sql_rows(context) == ROWS

Reply via email to