blob: c3ab075ebc693687e3c96dc970ba38d54a5752c6 [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 unittest
from unittest.mock import patch
import pyarrow as pa
import pytest
from types import SimpleNamespace
from pypaimon import Schema
from pypaimon.api.api_response import AuthTableQueryResponse
from pypaimon.catalog.catalog_exception import TableNoPermissionException
from pypaimon.catalog.table_query_auth import (
TableQueryAuthResult,
)
from pypaimon.common.identifier import Identifier
from pypaimon.common.options import Options
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.read.plan import Plan
from pypaimon.read.query_auth_split import QueryAuthSplit
from pypaimon.read.scan_stats import ScanStats
from pypaimon.read.table_scan import TableScan
from pypaimon.tests import native_plan_rest_test
rest_catalog = native_plan_rest_test.rest_catalog
class _FakeField:
def __init__(self, name):
self.name = name
class _FakeSplit:
def __init__(self, name="s1"):
self.name = name
@property
def row_count(self):
return 100
@property
def files(self):
return []
@property
def partition(self):
return {}
@property
def bucket(self):
return 0
def merged_row_count(self):
return 100
class _FakeTable:
def __init__(self, identifier=None):
self.identifier = identifier or Identifier("db", "table")
self.options = SimpleNamespace(
native_plan_enabled=lambda: False, native_read_enabled=lambda: False, query_auth_enabled=False)
class _FakeScan(TableScan):
def __init__(self, plan, identifier=None, auth_result=None):
self._plan = plan
self._query_auth_fn = None
self.table = _FakeTable(identifier)
self.file_scanner = _FakeFileScanner(plan)
self._auth_result = auth_result
def plan(self):
return self._plan
def _TableScan__auth_query(self, all_columns=False):
return self._auth_result
class _FakeFileScanner:
def __init__(self, plan):
self._plan = plan
self.skip_level0 = False
def scan(self):
return self._plan
def _simple_filter_json(field_name="dept", value="eng"):
return json.dumps({
"kind": "LEAF",
"transform": {
"name": "FIELD_REF",
"fieldRef": {"index": 0, "name": field_name, "type": "STRING"},
},
"function": "EQUAL",
"literals": [value],
})
class TestTableNoPermissionException(unittest.TestCase):
def test_message_format(self):
identifier = Identifier("db", "table")
exc = TableNoPermissionException(identifier)
self.assertIn("db.table", str(exc))
self.assertIn("No permission", str(exc))
self.assertEqual(exc.identifier, identifier)
class TestTableQueryAuthResultConvertPlan(unittest.TestCase):
def test_no_auth_returns_original_plan(self):
result = TableQueryAuthResult(None, None)
plan = Plan([_FakeSplit()])
converted = result.convert_plan(plan)
self.assertIs(converted, plan)
def test_no_auth_has_no_restrictions(self):
result = TableQueryAuthResult(None, None)
self.assertFalse(result.has_restrictions)
def test_empty_filter_and_masking_returns_original(self):
result = TableQueryAuthResult([], {})
plan = Plan([_FakeSplit()])
converted = result.convert_plan(plan)
self.assertIs(converted, plan)
def test_empty_filter_and_masking_has_no_restrictions(self):
result = TableQueryAuthResult([], {})
self.assertFalse(result.has_restrictions)
def test_filter_has_restrictions(self):
result = TableQueryAuthResult([_simple_filter_json()], None)
self.assertTrue(result.has_restrictions)
def test_masking_has_restrictions(self):
result = TableQueryAuthResult(None, {"col": '{"name":"NULL"}'})
self.assertTrue(result.has_restrictions)
def test_blank_filter_entry_is_rejected(self):
# Skipping it would read every row.
for rules in (["", _simple_filter_json()], [None]):
with self.assertRaisesRegex(ValueError, "Row filter cannot be empty"):
TableQueryAuthResult(rules, None)
def test_blank_column_masking_transform_is_rejected(self):
# Skipping it would return the column unmasked.
with self.assertRaisesRegex(ValueError, "Column masking on 'col' cannot be empty"):
TableQueryAuthResult(None, {"col": "", "col3": '{"name":"NULL"}'})
def test_blank_column_masking_target_is_rejected(self):
with self.assertRaisesRegex(ValueError, "Column masking target cannot be empty"):
TableQueryAuthResult(None, {"": '{"name":"NULL"}'})
def test_malformed_filter_is_rejected(self):
for rules in ({}, "", 0, False, {"a": "b"}):
with self.assertRaisesRegex(ValueError, "Row filter must be a list"):
TableQueryAuthResult(rules, None)
def test_malformed_column_masking_is_rejected(self):
for masking in ([], "", 0, False, ['{"name":"NULL"}']):
with self.assertRaisesRegex(ValueError, "Column masking must be a map"):
TableQueryAuthResult(None, masking)
def test_wraps_splits_with_filter(self):
result = TableQueryAuthResult([_simple_filter_json()], None)
stats = ScanStats(entries_after_stats=2)
plan = Plan([_FakeSplit("s1"), _FakeSplit("s2")], _scan_stats=stats)
converted = result.convert_plan(plan)
self.assertIs(converted.stats(), stats)
self.assertEqual(len(converted.splits()), 2)
for qs in converted.splits():
self.assertIsInstance(qs, QueryAuthSplit)
def test_auth_wrapping_preserves_lazy_native_statistics(self):
result = TableQueryAuthResult([_simple_filter_json()], None)
handle = object()
plan = Plan([_FakeSplit("s1")], snapshot_id=7, _native_stats=handle)
counters = ScanStats.empty_native()
counters.entries_total = 10
counters.planning_counters['manifest_entries_read'] = 10
with patch.object(ScanStats, 'from_native', return_value=counters) as convert:
wrapped = result.convert_plan(plan)
convert.assert_not_called()
self.assertEqual(wrapped.snapshot_id, 7)
self.assertIsInstance(wrapped.splits()[0], QueryAuthSplit)
self.assertIs(wrapped.stats(), counters)
self.assertIs(wrapped.stats(), counters)
self.assertEqual(counters.planning_counters['manifest_entries_read'], 10)
self.assertEqual(counters.planning_counters['final_splits'], 1)
convert.assert_called_once_with(handle)
def test_wraps_splits_with_masking(self):
result = TableQueryAuthResult(None, {"col": '{"name":"NULL"}'})
plan = Plan([_FakeSplit()])
converted = result.convert_plan(plan)
self.assertEqual(len(converted.splits()), 1)
self.assertIsInstance(converted.splits()[0], QueryAuthSplit)
def test_inner_split_preserved(self):
result = TableQueryAuthResult([_simple_filter_json()], None)
plan = Plan([_FakeSplit("original")])
converted = result.convert_plan(plan)
self.assertEqual(converted.splits()[0].split.name, "original")
class TestTableQueryAuthResultExtractRowFilter(unittest.TestCase):
def test_no_filter_returns_none(self):
result = TableQueryAuthResult(None, None)
self.assertIsNone(result.extract_row_filter())
def test_single_filter(self):
result = TableQueryAuthResult([_simple_filter_json("dept", "eng")], None)
fn = result.extract_row_filter()
self.assertIsNotNone(fn)
batch = pa.RecordBatch.from_pydict({"dept": ["eng", "sales", "eng"]})
mask = fn(batch)
self.assertEqual(mask.to_pylist(), [True, False, True])
def test_multiple_filters_combined_with_and(self):
f1 = _simple_filter_json("dept", "eng")
f2 = json.dumps({
"kind": "LEAF",
"transform": {
"name": "FIELD_REF",
"fieldRef": {"index": 0, "name": "dept", "type": "STRING"},
},
"function": "NOT_EQUAL",
"literals": ["eng"],
})
result = TableQueryAuthResult([f1, f2], None)
fn = result.extract_row_filter()
batch = pa.RecordBatch.from_pydict({"dept": ["eng", "sales", "eng"]})
mask = fn(batch)
self.assertEqual(mask.to_pylist(), [False, False, False])
def test_filter_entries_short_circuit_in_server_order(self):
first = _simple_filter_json('dept', 'eng')
second = json.dumps({
'kind': 'LEAF', 'function': 'EQUAL', 'literals': ['a'],
'transform': {'name': 'SUBSTRING', 'inputs': [
{'name': 's', 'type': 'STRING'}, {'name': 'position', 'type': 'BIGINT'}, 1]},
})
batch = pa.RecordBatch.from_pydict({
'dept': ['eng', 'sales'], 's': ['abc', 'xyz'], 'position': [1, 2147483648]})
result = TableQueryAuthResult([first, second], None).extract_row_filter()
self.assertEqual(result(batch).to_pylist(), [True, False])
# Reversing the response's order executes the throwing transform first.
with self.assertRaises(ValueError):
TableQueryAuthResult([second, first], None).extract_row_filter()(batch)
class TestTableQueryAuthResultExtraFields(unittest.TestCase):
def test_detects_unprojected_field(self):
read_fields = [_FakeField("name"), _FakeField("age")]
table_fields = [
_FakeField("name"),
_FakeField("age"),
_FakeField("dept"),
]
result = TableQueryAuthResult([_simple_filter_json("dept")], None)
extra = result.get_extra_fields(read_fields, table_fields)
self.assertEqual(len(extra), 1)
self.assertEqual(extra[0].name, "dept")
def test_no_extra_when_already_projected(self):
read_fields = [_FakeField("name"), _FakeField("dept")]
table_fields = read_fields + [_FakeField("age")]
result = TableQueryAuthResult([_simple_filter_json("dept")], None)
extra = result.get_extra_fields(read_fields, table_fields)
self.assertEqual(len(extra), 0)
def test_no_extra_when_no_filter(self):
result = TableQueryAuthResult(None, None)
extra = result.get_extra_fields(
[_FakeField("a")], [_FakeField("a"), _FakeField("b")]
)
self.assertEqual(len(extra), 0)
def test_deduplicates_extra_fields(self):
f1 = _simple_filter_json("dept", "eng")
f2 = _simple_filter_json("dept", "sales")
read_fields = [_FakeField("name")]
table_fields = [_FakeField("name"), _FakeField("dept")]
result = TableQueryAuthResult([f1, f2], None)
extra = result.get_extra_fields(read_fields, table_fields)
self.assertEqual(len(extra), 1)
class TestQueryAuthSplit(unittest.TestCase):
def test_delegates_properties(self):
auth = TableQueryAuthResult([_simple_filter_json()], None)
split = _FakeSplit()
qs = QueryAuthSplit(split, auth)
self.assertEqual(qs.row_count, 100)
self.assertEqual(qs.bucket, 0)
self.assertEqual(qs.files, [])
self.assertEqual(qs.partition, {})
def test_merged_row_count_none_with_filter(self):
auth = TableQueryAuthResult([_simple_filter_json()], None)
qs = QueryAuthSplit(_FakeSplit(), auth)
self.assertIsNone(qs.merged_row_count())
def test_merged_row_count_delegates_without_filter(self):
auth = TableQueryAuthResult(None, {"col": '{"name":"NULL"}'})
qs = QueryAuthSplit(_FakeSplit(), auth)
self.assertEqual(qs.merged_row_count(), 100)
def test_exposes_inner_split(self):
split = _FakeSplit("inner")
qs = QueryAuthSplit(split, TableQueryAuthResult(None, None))
self.assertIs(qs.split, split)
def test_exposes_auth_result(self):
auth = TableQueryAuthResult(None, None)
qs = QueryAuthSplit(_FakeSplit(), auth)
self.assertIs(qs.auth_result, auth)
class TestQueryAuthSplitRawConvertible(unittest.TestCase):
def test_raw_convertible_proxy_true(self):
inner = _FakeSplit()
inner.raw_convertible = True
auth_split = QueryAuthSplit(inner, None)
self.assertTrue(auth_split.raw_convertible)
def test_raw_convertible_proxy_false(self):
inner = _FakeSplit()
inner.raw_convertible = False
auth_split = QueryAuthSplit(inner, None)
self.assertFalse(auth_split.raw_convertible)
class TestQueryAuthSplitAttributeDelegation(unittest.TestCase):
def test_file_size_delegated(self):
inner = _FakeSplit()
inner.file_size = 12345
auth_split = QueryAuthSplit(inner, TableQueryAuthResult(None, None))
self.assertEqual(auth_split.file_size, 12345)
def test_file_paths_delegated(self):
inner = _FakeSplit()
inner.file_paths = ["/data/file1.parquet", "/data/file2.parquet"]
auth_split = QueryAuthSplit(inner, TableQueryAuthResult(None, None))
self.assertEqual(auth_split.file_paths, ["/data/file1.parquet", "/data/file2.parquet"])
def test_data_deletion_files_delegated(self):
inner = _FakeSplit()
inner.data_deletion_files = [None, "dv-1"]
auth_split = QueryAuthSplit(inner, TableQueryAuthResult(None, None))
self.assertEqual(auth_split.data_deletion_files, [None, "dv-1"])
def test_unknown_attr_raises(self):
inner = _FakeSplit()
auth_split = QueryAuthSplit(inner, TableQueryAuthResult(None, None))
with self.assertRaises(AttributeError):
_ = auth_split.completely_nonexistent_attr
def test_pickle_roundtrip(self):
import pickle
inner = _FakeSplit()
inner.file_size = 999
auth_split = QueryAuthSplit(inner, TableQueryAuthResult(None, None))
restored = pickle.loads(pickle.dumps(auth_split))
self.assertEqual(restored.file_size, 999)
self.assertEqual(restored.row_count, 100)
class TestPlanAllFiles(unittest.TestCase):
def test_no_restriction_returns_plan_unchanged(self):
from pypaimon.read.table_scan import TableScan
plain_plan = Plan([_FakeSplit()], snapshot_id=5)
scan = _FakeScan(plain_plan, auth_result=None)
result = TableScan.plan_all(scan)
self.assertIs(result, plain_plan)
def test_row_filter_restriction_raises(self):
from pypaimon.read.table_scan import TableScan
auth = TableQueryAuthResult([_simple_filter_json()], None)
plan = Plan([_FakeSplit()], snapshot_id=5)
scan = _FakeScan(plan, auth_result=auth)
with self.assertRaises(TableNoPermissionException):
TableScan.plan_all(scan)
def test_column_masking_restriction_raises(self):
from pypaimon.read.table_scan import TableScan
auth = TableQueryAuthResult(None, {"col": '{"name":"NULL"}'})
plan = Plan([_FakeSplit()], snapshot_id=5)
scan = _FakeScan(plan, auth_result=auth)
with self.assertRaises(TableNoPermissionException):
TableScan.plan_all(scan)
def test_empty_table_with_auth_still_raises(self):
from pypaimon.read.table_scan import TableScan
auth = TableQueryAuthResult([_simple_filter_json()], None)
empty_plan = Plan([], snapshot_id=5)
scan = _FakeScan(empty_plan, auth_result=auth)
with self.assertRaises(TableNoPermissionException):
TableScan.plan_all(scan)
def test_no_restrictions_allows_write(self):
from pypaimon.read.table_scan import TableScan
plan = Plan([_FakeSplit()], snapshot_id=5)
scan = _FakeScan(plan, auth_result=None)
result = TableScan.plan_all(scan)
self.assertIs(result, plan)
class TestResolveAuthResult(unittest.TestCase):
def test_none_fn_returns_none(self):
from pypaimon.read.query_auth_split import resolve_auth_result
self.assertIsNone(resolve_auth_result(None, None))
def test_no_restrictions_returns_none(self):
from pypaimon.read.query_auth_split import resolve_auth_result
fn = lambda select: TableQueryAuthResult(None, None)
self.assertIsNone(resolve_auth_result(fn, None))
def test_empty_filter_returns_none(self):
from pypaimon.read.query_auth_split import resolve_auth_result
fn = lambda select: TableQueryAuthResult([], {})
self.assertIsNone(resolve_auth_result(fn, None))
def test_blank_filter_fails_resolution(self):
from pypaimon.read.query_auth_split import resolve_auth_result
fn = lambda select: TableQueryAuthResult(["", None], None)
with self.assertRaisesRegex(ValueError, "Row filter cannot be empty"):
resolve_auth_result(fn, None)
def test_with_filter_returns_result(self):
from pypaimon.read.query_auth_split import resolve_auth_result
fn = lambda select: TableQueryAuthResult([_simple_filter_json()], None)
result = resolve_auth_result(fn, None)
self.assertIsNotNone(result)
self.assertTrue(result.has_restrictions)
def test_with_masking_returns_result(self):
from pypaimon.read.query_auth_split import resolve_auth_result
fn = lambda select: TableQueryAuthResult(None, {"col": '{"name":"NULL"}'})
result = resolve_auth_result(fn, None)
self.assertIsNotNone(result)
self.assertTrue(result.has_restrictions)
class TestCoreOptionsQueryAuth(unittest.TestCase):
def test_disabled_by_default(self):
opts = CoreOptions(Options({}))
self.assertFalse(opts.query_auth_enabled)
def test_enabled_when_set(self):
opts = CoreOptions(Options({"query-auth.enabled": True}))
self.assertTrue(opts.query_auth_enabled)
def test_disabled_when_false(self):
opts = CoreOptions(Options({"query-auth.enabled": False}))
self.assertFalse(opts.query_auth_enabled)
@pytest.mark.parametrize("copy_method", ["copy", "copy_without_time_travel"])
@pytest.mark.parametrize("enabled", ["true", True, "on"])
def test_query_auth_cannot_be_disabled_dynamically(rest_catalog, copy_method, enabled):
catalog, server = rest_catalog
schema = pa.schema([("dept", pa.string()), ("secret", pa.string())])
catalog.create_table("default.auth", Schema.from_pyarrow_schema(
schema, options={"query-auth.enabled": enabled}), False)
table = catalog.get_table("default.auth")
builder = table.new_batch_write_builder()
writer = builder.new_write()
commit = builder.new_commit()
try:
writer.write_arrow(pa.Table.from_pydict({
"dept": ["eng", "other"], "secret": ["secret1", "secret2"],
}, schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
server.table_query_auth_store[table.identifier.get_full_name()] = AuthTableQueryResponse(
filter=[_simple_filter_json()], column_masking={"secret": '{"name":"NULL"}'})
copy = getattr(table, copy_method)
for value in ("false", "FaLsE", False, None, "", "invalid", "0", 0, "off", "no"):
with pytest.raises(ValueError) as error:
copy({"query-auth.enabled": value})
assert str(error.value) == "Option 'query-auth.enabled' is not allowed to be disabled dynamically"
for options in [{}] + [{"query-auth.enabled": value} for value in ("true", "TrUe", True, " on ", 1)]:
copied = copy(options)
read = copied.new_read_builder()
result = read.new_read().to_arrow(read.new_scan().plan().splits())
assert result.to_pydict() == {"dept": ["eng"], "secret": [None]}
with pytest.raises(ValueError, match="Search is not supported on a query-auth table"):
copied.new_vector_search_builder().new_vector_search_scan()
assert table.table_schema.options["query-auth.enabled"] == enabled
@pytest.mark.parametrize("copy_method", ["copy", "copy_without_time_travel"])
@pytest.mark.parametrize("options", [{}, {"query-auth.enabled": "false"}])
def test_query_auth_can_be_enabled_dynamically(rest_catalog, copy_method, options):
catalog, _ = rest_catalog
catalog.create_table("default.plain", Schema.from_pyarrow_schema(
pa.schema([("secret", pa.string())]), options=options), False)
table = catalog.get_table("default.plain")
for value, enabled in (("false", False), (False, False), (None, False),
("true", True), ("TrUe", True), (True, True), ("on", True)):
copied = getattr(table, copy_method)({"query-auth.enabled": value})
assert copied.options.query_auth_enabled == enabled
assert not table.options.query_auth_enabled
if __name__ == "__main__":
unittest.main()
class TestConvertPlanPreservesSnapshotId(unittest.TestCase):
def test_convert_plan_preserves_snapshot_id(self):
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
from pypaimon.read.plan import Plan
from unittest.mock import MagicMock
split = MagicMock()
original_plan = Plan([split], snapshot_id=42)
predicate_json = (
'{"kind":"LEAF","transform":{"name":"FIELD_REF",'
'"fieldRef":{"name":"id"}},"function":"EQUAL","literals":[1]}'
)
auth_result = TableQueryAuthResult(
filter=[predicate_json],
column_masking=None)
converted = auth_result.convert_plan(original_plan)
assert converted.snapshot_id == 42
def test_convert_plan_preserves_none_snapshot_id(self):
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
from pypaimon.read.plan import Plan
from unittest.mock import MagicMock
split = MagicMock()
original_plan = Plan([split], snapshot_id=None)
predicate_json = (
'{"kind":"LEAF","transform":{"name":"FIELD_REF",'
'"fieldRef":{"name":"id"}},"function":"EQUAL","literals":[1]}'
)
auth_result = TableQueryAuthResult(
filter=[predicate_json],
column_masking=None)
converted = auth_result.convert_plan(original_plan)
assert converted.snapshot_id is None