|
| 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