blob: db426f970546cba441cfca826783b4a5debddcf2 [file]
################################################################################
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
################################################################################
import json
import os
import shutil
import tempfile
import unittest
import pyarrow as pa
from pypaimon import CatalogFactory, Schema
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
from pypaimon.schema.data_types import AtomicType, DataField
def _field_ref(index, name, type_name="STRING"):
return {"name": "FIELD_REF",
"fieldRef": {"index": index, "name": name, "type": type_name}}
def _null_mask():
return json.dumps({"name": "NULL"})
def _leaf(index, name, function, literals, type_name="INT"):
return json.dumps({
"kind": "LEAF",
"transform": _field_ref(index, name, type_name),
"function": function,
"literals": literals,
})
def _fields(*names):
return [DataField(i, n, AtomicType("STRING")) for i, n in enumerate(names)]
class TestExtraFieldsWidening(unittest.TestCase):
def _extra(self, result, read, table):
return [f.name for f in result.get_extra_fields(
_fields(*read), _fields(*table))]
def test_filter_operands_widen(self):
result = TableQueryAuthResult(
filter=[_leaf(2, "score", "GREATER_THAN", [5])], column_masking=None)
self.assertEqual(self._extra(result, ["id"], ["id", "name", "score"]),
["score"])
def test_readable_mask_input_widens(self):
result = TableQueryAuthResult(filter=None, column_masking={
"secret": json.dumps(_field_ref(2, "salt"))})
self.assertEqual(
self._extra(result, ["id", "secret"], ["id", "secret", "salt"]),
["salt"])
def test_unreadable_mask_target_widens_nothing(self):
result = TableQueryAuthResult(filter=None, column_masking={
"secret": json.dumps(_field_ref(2, "salt"))})
self.assertEqual(self._extra(result, ["id"], ["id", "secret", "salt"]), [])
def test_widening_is_transitive(self):
result = TableQueryAuthResult(filter=None, column_masking={
"a": json.dumps(_field_ref(1, "b")),
"b": json.dumps(_field_ref(2, "c")),
})
self.assertEqual(self._extra(result, ["a"], ["a", "b", "c"]), ["b", "c"])
def test_a_filter_operand_makes_its_mask_inputs_needed(self):
result = TableQueryAuthResult(
filter=[_leaf(1, "b", "GREATER_THAN", [5])],
column_masking={"b": json.dumps(_field_ref(2, "c"))})
self.assertEqual(self._extra(result, ["a"], ["a", "b", "c"]), ["b", "c"])
def test_a_name_absent_from_the_table_is_skipped(self):
result = TableQueryAuthResult(
filter=[_leaf(9, "gone", "GREATER_THAN", [5])], column_masking=None)
self.assertEqual(self._extra(result, ["id"], ["id"]), [])
def test_no_restrictions_widens_nothing(self):
self.assertEqual(
self._extra(TableQueryAuthResult(filter=None, column_masking=None),
["id"], ["id", "score"]), [])
class TestWidenedReadsDoNotCrash(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tempdir = tempfile.mkdtemp()
cls.catalog = CatalogFactory.create(
{'warehouse': os.path.join(cls.tempdir, 'wh')})
cls.catalog.create_database('db', True)
cls.flat_schema = pa.schema([
('id', pa.int32()), ('secret', pa.string()), ('salt', pa.string()),
('score', pa.int32())])
cls._create('flat', cls.flat_schema, {
'id': [1, 2], 'secret': ['a', 'b'], 'salt': ['x', 'y'],
'score': [1, 9]})
cls.nested_schema = pa.schema([
('id', pa.int32()),
('s', pa.struct([('a', pa.string()), ('b', pa.string())])),
('score', pa.int32())])
cls._create('nested', cls.nested_schema, {
'id': [1, 2],
's': [{'a': 'a1', 'b': 'b1'}, {'a': 'a2', 'b': 'b2'}],
'score': [1, 9]})
@classmethod
def _create(cls, name, pa_schema, rows):
cls.catalog.create_table(
'db.' + name, Schema.from_pyarrow_schema(pa_schema), False)
table = cls.catalog.get_table('db.' + name)
writer = table.new_batch_write_builder().new_write()
writer.write_arrow(pa.Table.from_pydict(rows, schema=pa_schema))
table.new_batch_write_builder().new_commit().commit(
writer.prepare_commit())
writer.close()
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tempdir, ignore_errors=True)
def _read(self, name, projection, auth_result=None):
table = self.catalog.get_table('db.' + name)
if auth_result is not None:
table.catalog_environment.table_query_auth = (
lambda options, identifier: (lambda select: auth_result))
builder = table.new_read_builder()
if projection is not None:
builder = builder.with_projection(projection)
splits = builder.new_scan().plan().splits()
return builder.new_read().to_arrow(splits).to_pydict()
def test_mask_input_outside_the_projection(self):
out = self._read('flat', ['id', 'secret'], TableQueryAuthResult(
filter=None,
column_masking={'secret': json.dumps(_field_ref(2, 'salt'))}))
self.assertEqual(out, {'id': [1, 2], 'secret': ['x', 'y']})
def test_a_mask_reading_a_masked_column_is_still_refused(self):
with self.assertRaises(ValueError) as ctx:
self._read('flat', ['id', 'secret'], TableQueryAuthResult(
filter=None,
column_masking={'secret': json.dumps(_field_ref(2, 'salt')),
'salt': _null_mask()}))
self.assertIn("which is masked too", str(ctx.exception))
def test_filter_operand_outside_the_projection(self):
out = self._read('flat', ['id', 'secret'], TableQueryAuthResult(
filter=[_leaf(3, 'score', 'GREATER_THAN', [5])],
column_masking=None))
self.assertEqual(out, {'id': [2], 'secret': ['b']})
def test_nested_projection_with_filter_on_an_unprojected_column(self):
out = self._read('nested', ['id', 's.a'], TableQueryAuthResult(
filter=[_leaf(2, 'score', 'GREATER_THAN', [5])],
column_masking=None))
self.assertEqual(out, {'id': [2], 's_a': ['a2']})
def test_nested_projection_with_a_mask_input_outside_the_projection(self):
out = self._read('nested', ['id', 's.a'], TableQueryAuthResult(
filter=None,
column_masking={'id': json.dumps(_field_ref(2, 'score', 'INT'))}))
self.assertEqual(out, {'id': [1, 9], 's_a': ['a1', 'a2']})
def test_nested_projection_without_authorization_is_unchanged(self):
self.assertEqual(self._read('nested', ['id', 's.a']),
{'id': [1, 2], 's_a': ['a1', 'a2']})
if __name__ == '__main__':
unittest.main()