Skip to content

Commit 3e1697e

Browse files
[python] Test permissions and query auth across PyPaimon and DataFusion SQL
The mock REST server gains the permission, policy and AuthTableQuery endpoints, after Java RESTCatalogServer: - Grants are removed or moved with the table, database or function they name, and COLUMN grants follow column renames. - Per-table policies need query-auth.enabled, conflict as POLICY, must name existing columns and go away with their table. - A schema change is refused before it is committed if it would disable query authorization or drop or rename a column that a policy or the last allowed column of a COLUMN grant needs. - The auth endpoint checks the selected columns against the COLUMN grants of the principals the caller acts as, and answers with their policies; two masks on one column deny the query. sql_permission_test.py runs in the Rust main job and checks that the two clients agree: grants and policies made with the CALL sys.* procedures are listed by PyPaimon and the other way round, and both readers return the same rows and values under a row filter, column masks and a COLUMN grant. rest_permission_server_test.py covers the mock server's rules.
1 parent eed721f commit 3e1697e

3 files changed

Lines changed: 815 additions & 6 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
import json
19+
20+
import pyarrow as pa
21+
import pytest
22+
23+
from pypaimon import CatalogFactory, Schema
24+
from pypaimon.api.api_response import ConfigResponse
25+
from pypaimon.api.auth import BearTokenAuthProvider
26+
from pypaimon.api.rest_exception import (AlreadyExistsException, BadRequestException,
27+
NoSuchResourceException)
28+
from pypaimon.catalog.catalog_exception import TableNoPermissionException
29+
from pypaimon.common.identifier import Identifier
30+
from pypaimon.management.column_mask import ColumnMask
31+
from pypaimon.management.data_policy import DataPolicy
32+
from pypaimon.management.list_permissions_request import ListPermissionsRequest
33+
from pypaimon.management.list_policies_request import ListPoliciesRequest
34+
from pypaimon.management.permission_assignment import PermissionAssignment
35+
from pypaimon.management.permission_columns import PermissionColumns
36+
from pypaimon.management.permission_resource import PermissionResource
37+
from pypaimon.management.resource_type import ResourceType
38+
from pypaimon.schema.schema_change import SchemaChange
39+
from pypaimon.tests.rest.rest_function_test import _mock_function
40+
from pypaimon.tests.rest.rest_server import RESTCatalogServer
41+
42+
NULL_MASK = json.dumps({'name': 'NULL'})
43+
STARS_MASK = json.dumps({'name': 'CONCAT', 'inputs': ['***']})
44+
USERS = Identifier.create('default', 'users')
45+
COLUMNS = PermissionResource(ResourceType.COLUMN, 'default', 'users')
46+
47+
48+
def _schema(query_auth=True):
49+
return Schema.from_pyarrow_schema(pa.schema([('id', pa.int64()), ('email', pa.string())]),
50+
options={'query-auth.enabled': 'true'} if query_auth else {})
51+
52+
53+
@pytest.fixture
54+
def rest(tmp_path):
55+
server = RESTCatalogServer(str(tmp_path), BearTokenAuthProvider('token'),
56+
ConfigResponse(defaults={'prefix': 'mock'}), 'warehouse')
57+
server.start()
58+
try:
59+
catalog = CatalogFactory.create({
60+
'metastore': 'rest', 'uri': server.get_url(), 'warehouse': 'warehouse',
61+
'token.provider': 'bear', 'token': 'token', 'data-token.enabled': 'false'})
62+
catalog.create_database('default', False)
63+
catalog.create_table('default.users', _schema(), False)
64+
yield catalog, server
65+
finally:
66+
server.shutdown()
67+
68+
69+
def _table(name):
70+
return PermissionResource(ResourceType.TABLE, 'default', name)
71+
72+
73+
def _restrict(catalog, name):
74+
catalog.permission_management().grant_permission(PermissionAssignment(_table(name), 'SELECT', 'analyst'))
75+
catalog.policy_management().create_policy(
76+
DataPolicy.column_mask(_table(name), ColumnMask('email', NULL_MASK), 'analyst'))
77+
78+
79+
def _access(catalog, name):
80+
grants = catalog.permission_management().list_permissions(
81+
ListPermissionsRequest(ResourceType.TABLE, 'default', name)).elements
82+
policies = catalog.policy_management().list_policies(ListPoliciesRequest(_table(name))).elements
83+
return ([(grant.get_access(), grant.get_principal()) for grant in grants],
84+
[(policy.get_resource().get_table(), policy.get_column_mask().get_on_column())
85+
for policy in policies])
86+
87+
88+
def test_table_grants_and_policies_follow_rename_and_drop(rest):
89+
catalog, _ = rest
90+
_restrict(catalog, 'users')
91+
92+
catalog.rename_table('default.users', 'default.members')
93+
assert _access(catalog, 'members') == ([('SELECT', 'analyst')], [('members', 'email')])
94+
catalog.create_table('default.users', _schema(), False)
95+
assert _access(catalog, 'users') == ([], [])
96+
97+
catalog.drop_table('default.members')
98+
catalog.create_table('default.members', _schema(), False)
99+
assert _access(catalog, 'members') == ([], [])
100+
101+
102+
def test_dropping_a_database_or_function_removes_its_grants(rest):
103+
catalog, server = rest
104+
area = Identifier.create('default', 'area')
105+
catalog.create_function(area, _mock_function(area), False)
106+
catalog.create_database('sales', False)
107+
for resource, access in ((PermissionResource(ResourceType.DATABASE, 'sales'), 'DESCRIBE'),
108+
(PermissionResource(ResourceType.FUNCTION, 'default', function='area'), 'SELECT'),
109+
(PermissionResource(ResourceType.DATABASE, 'default'), 'DESCRIBE')):
110+
catalog.permission_management().grant_permission(PermissionAssignment(resource, access, 'analyst'))
111+
112+
catalog.drop_database('sales')
113+
catalog.drop_function(area, False)
114+
assert [(grant.get_resource().get_type(), grant.get_resource().get_database())
115+
for grant in server.permission_store.values()] == [(ResourceType.DATABASE, 'default')]
116+
117+
118+
@pytest.mark.parametrize('expire_time, expired', [
119+
(None, False),
120+
('2099-01-01T00:00:00.123000000Z', False),
121+
('2099-01-01t00:00:00.5z', False),
122+
('+12000-01-01T00:00:00Z', False),
123+
('2020-01-01T24:00:00.000000000Z', True),
124+
('-0001-01-01T00:00:00.999Z', True),
125+
])
126+
def test_column_grants_limit_the_selected_columns_until_they_expire(rest, expire_time, expired):
127+
catalog, server = rest
128+
catalog.permission_management().grant_permission(PermissionAssignment(
129+
COLUMNS, 'SELECT', 'analyst', PermissionColumns(excluded_column_names=['email']), expire_time))
130+
131+
server.set_query_principals('analyst')
132+
catalog.auth_table_query(USERS, ['id'])
133+
for select in (['email'], None):
134+
if expired:
135+
catalog.auth_table_query(USERS, select)
136+
else:
137+
with pytest.raises(TableNoPermissionException):
138+
catalog.auth_table_query(USERS, select)
139+
140+
141+
def test_column_grants_and_policies_need_query_auth_and_existing_columns(rest):
142+
catalog, _ = rest
143+
catalog.create_table('default.plain', _schema(query_auth=False), False)
144+
with pytest.raises(AlreadyExistsException):
145+
catalog.permission_management().grant_permission(PermissionAssignment(
146+
PermissionResource(ResourceType.COLUMN, 'default', 'plain'), 'SELECT', 'analyst',
147+
PermissionColumns(column_names=['id'])))
148+
with pytest.raises(NoSuchResourceException):
149+
catalog.permission_management().grant_permission(PermissionAssignment(
150+
COLUMNS, 'SELECT', 'analyst', PermissionColumns(column_names=['phone'])))
151+
with pytest.raises(BadRequestException):
152+
catalog.policy_management().create_policy(
153+
DataPolicy.column_mask(_table('users'), ColumnMask('phone', NULL_MASK), 'analyst'))
154+
155+
156+
def test_schema_changes_cannot_orphan_policies(rest):
157+
catalog, _ = rest
158+
_restrict(catalog, 'users')
159+
schema = catalog.get_table('default.users').table_schema
160+
for change in (SchemaChange.set_option('query-auth.enabled', 'false'),
161+
SchemaChange.remove_option('query-auth.enabled'),
162+
SchemaChange.rename_column('email', 'contact'),
163+
SchemaChange.drop_column('email')):
164+
with pytest.raises(BadRequestException):
165+
catalog.alter_table('default.users', [change])
166+
167+
current = catalog.get_table('default.users').table_schema
168+
assert (current.id, current.options) == (schema.id, schema.options)
169+
assert _access(catalog, 'users')[1] == [('users', 'email')]
170+
catalog.alter_table('default.users', [SchemaChange.rename_column('id', 'user_id')])
171+
172+
173+
def test_column_grants_follow_column_renames(rest):
174+
catalog, _ = rest
175+
catalog.permission_management().grant_permission(PermissionAssignment(
176+
COLUMNS, 'SELECT', 'analyst', PermissionColumns(column_names=['id', 'email'])))
177+
178+
catalog.alter_table('default.users', [SchemaChange.rename_column('email', 'contact')])
179+
catalog.alter_table('default.users', [SchemaChange.drop_column('id')])
180+
grants = catalog.permission_management().list_permissions(
181+
ListPermissionsRequest(ResourceType.COLUMN, 'default', 'users')).elements
182+
assert [grant.get_columns().get_column_names() for grant in grants] == [('contact',)]
183+
with pytest.raises(BadRequestException):
184+
catalog.alter_table('default.users', [SchemaChange.drop_column('contact')])
185+
186+
187+
def test_masks_of_two_query_principals_on_one_column_deny_the_query(rest):
188+
catalog, server = rest
189+
for principal, transform in (('analyst', NULL_MASK), ('auditor', STARS_MASK)):
190+
catalog.policy_management().create_policy(
191+
DataPolicy.column_mask(_table('users'), ColumnMask('email', transform), principal))
192+
193+
server.set_query_principals('analyst')
194+
assert catalog.auth_table_query(USERS).column_masking == {'email': NULL_MASK}
195+
server.set_query_principals('analyst', 'auditor')
196+
with pytest.raises(TableNoPermissionException):
197+
catalog.auth_table_query(USERS)

0 commit comments

Comments
 (0)