Skip to content

Commit 8ce21cf

Browse files
committed
[python] Fix write-path auth bypass and unify auth restriction check
1 parent 30e2dc1 commit 8ce21cf

4 files changed

Lines changed: 96 additions & 19 deletions

File tree

paimon-python/pypaimon/catalog/table_query_auth.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
extract_referenced_fields,
2626
parse_predicate_to_batch_filter,
2727
)
28+
from pypaimon.read.plan import Plan
29+
from pypaimon.read.query_auth_split import QueryAuthSplit
2830
from pypaimon.schema.data_types import DataField
2931

3032

@@ -37,11 +39,12 @@ def __init__(self, filter: Optional[List[str]], column_masking: Optional[Dict[st
3739
if column_masking else column_masking
3840
)
3941

40-
def convert_plan(self, plan):
41-
from pypaimon.read.query_auth_split import QueryAuthSplit
42-
from pypaimon.read.plan import Plan
42+
@property
43+
def has_restrictions(self):
44+
return bool(self.filter) or bool(self.column_masking)
4345

44-
if not self.filter and not self.column_masking:
46+
def convert_plan(self, plan):
47+
if not self.has_restrictions:
4548
return plan
4649
auth_splits = [QueryAuthSplit(split, self) for split in plan.splits()]
4750
return Plan(auth_splits, snapshot_id=plan.snapshot_id)

paimon-python/pypaimon/read/query_auth_split.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ def resolve_auth_result(query_auth_fn, read_type):
6868
if query_auth_fn is None:
6969
return None
7070
select = [f.name for f in read_type] if read_type else None
71-
return query_auth_fn(select)
71+
result = query_auth_fn(select)
72+
if result is None or not result.has_restrictions:
73+
return None
74+
return result
7275

7376

7477
def wrap_plan_with_auth(auth_result, plan):

paimon-python/pypaimon/read/table_scan.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from pypaimon.common.predicate_builder import PredicateBuilder
2525
from pypaimon.manifest.manifest_list_manager import ManifestListManager
2626
from pypaimon.read.plan import Plan
27-
from pypaimon.read.query_auth_split import QueryAuthSplit, resolve_auth_result, wrap_plan_with_auth
27+
from pypaimon.read.query_auth_split import resolve_auth_result, wrap_plan_with_auth
2828
from pypaimon.read.scan_stats import ScanStats
2929
from pypaimon.read.scanner.file_scanner import FileScanner
3030

@@ -58,10 +58,9 @@ def plan(self) -> Plan:
5858
return wrap_plan_with_auth(auth_result, plan)
5959

6060
def plan_for_write(self) -> Plan:
61-
plan = self.plan()
62-
if any(isinstance(s, QueryAuthSplit) for s in plan.splits()):
61+
if self.__auth_query() is not None:
6362
raise TableNoPermissionException(self.table.identifier)
64-
return plan
63+
return self.file_scanner.scan()
6564

6665
def __auth_query(self):
6766
return resolve_auth_result(self._query_auth_fn, self._read_type)

paimon-python/pypaimon/tests/table_query_auth_test.py

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,26 @@ def __init__(self, identifier=None):
7575

7676

7777
class _FakeScan:
78-
def __init__(self, plan, identifier=None):
78+
def __init__(self, plan, identifier=None, auth_result=None):
7979
self._plan = plan
8080
self.table = _FakeTable(identifier)
81+
self.file_scanner = _FakeFileScanner(plan)
82+
self._auth_result = auth_result
8183

8284
def plan(self):
8385
return self._plan
8486

87+
def _TableScan__auth_query(self):
88+
return self._auth_result
89+
90+
91+
class _FakeFileScanner:
92+
def __init__(self, plan):
93+
self._plan = plan
94+
95+
def scan(self):
96+
return self._plan
97+
8598

8699
def _simple_filter_json(field_name="dept", value="eng"):
87100
return json.dumps({
@@ -113,12 +126,28 @@ def test_no_auth_returns_original_plan(self):
113126
converted = result.convert_plan(plan)
114127
self.assertIs(converted, plan)
115128

129+
def test_no_auth_has_no_restrictions(self):
130+
result = TableQueryAuthResult(None, None)
131+
self.assertFalse(result.has_restrictions)
132+
116133
def test_empty_filter_and_masking_returns_original(self):
117134
result = TableQueryAuthResult([], {})
118135
plan = _FakePlan([_FakeSplit()])
119136
converted = result.convert_plan(plan)
120137
self.assertIs(converted, plan)
121138

139+
def test_empty_filter_and_masking_has_no_restrictions(self):
140+
result = TableQueryAuthResult([], {})
141+
self.assertFalse(result.has_restrictions)
142+
143+
def test_filter_has_restrictions(self):
144+
result = TableQueryAuthResult([_simple_filter_json()], None)
145+
self.assertTrue(result.has_restrictions)
146+
147+
def test_masking_has_restrictions(self):
148+
result = TableQueryAuthResult(None, {"col": '{"name":"NULL"}'})
149+
self.assertTrue(result.has_restrictions)
150+
122151
def test_blank_filter_entries_are_skipped(self):
123152
result = TableQueryAuthResult(["", None], None)
124153
self.assertFalse(result.filter)
@@ -334,38 +363,81 @@ def test_no_restriction_returns_plan_unchanged(self):
334363
from pypaimon.read.table_scan import TableScan
335364

336365
plain_plan = _FakePlan([_FakeSplit()], snapshot_id=5)
337-
scan = _FakeScan(plain_plan)
366+
scan = _FakeScan(plain_plan, auth_result=None)
338367
result = TableScan.plan_for_write(scan)
339368
self.assertIs(result, plain_plan)
340369

341370
def test_row_filter_restriction_raises(self):
342371
from pypaimon.read.table_scan import TableScan
343372

344373
auth = TableQueryAuthResult([_simple_filter_json()], None)
345-
wrapped_plan = _FakePlan([QueryAuthSplit(_FakeSplit(), auth)], snapshot_id=5)
346-
scan = _FakeScan(wrapped_plan)
374+
plan = _FakePlan([_FakeSplit()], snapshot_id=5)
375+
scan = _FakeScan(plan, auth_result=auth)
347376
with self.assertRaises(TableNoPermissionException):
348377
TableScan.plan_for_write(scan)
349378

350379
def test_column_masking_restriction_raises(self):
351380
from pypaimon.read.table_scan import TableScan
352381

353382
auth = TableQueryAuthResult(None, {"col": '{"name":"NULL"}'})
354-
wrapped_plan = _FakePlan([QueryAuthSplit(_FakeSplit(), auth)], snapshot_id=5)
355-
scan = _FakeScan(wrapped_plan)
383+
plan = _FakePlan([_FakeSplit()], snapshot_id=5)
384+
scan = _FakeScan(plan, auth_result=auth)
356385
with self.assertRaises(TableNoPermissionException):
357386
TableScan.plan_for_write(scan)
358387

359-
def test_mixed_splits_any_restricted_raises(self):
388+
def test_empty_table_with_auth_still_raises(self):
360389
from pypaimon.read.table_scan import TableScan
361390

362391
auth = TableQueryAuthResult([_simple_filter_json()], None)
363-
wrapped_plan = _FakePlan(
364-
[_FakeSplit(), QueryAuthSplit(_FakeSplit(), auth)], snapshot_id=5)
365-
scan = _FakeScan(wrapped_plan)
392+
empty_plan = _FakePlan([], snapshot_id=5)
393+
scan = _FakeScan(empty_plan, auth_result=auth)
366394
with self.assertRaises(TableNoPermissionException):
367395
TableScan.plan_for_write(scan)
368396

397+
def test_no_restrictions_allows_write(self):
398+
from pypaimon.read.table_scan import TableScan
399+
400+
plan = _FakePlan([_FakeSplit()], snapshot_id=5)
401+
scan = _FakeScan(plan, auth_result=None)
402+
result = TableScan.plan_for_write(scan)
403+
self.assertIs(result, plan)
404+
405+
406+
class TestResolveAuthResult(unittest.TestCase):
407+
408+
def test_none_fn_returns_none(self):
409+
from pypaimon.read.query_auth_split import resolve_auth_result
410+
self.assertIsNone(resolve_auth_result(None, None))
411+
412+
def test_no_restrictions_returns_none(self):
413+
from pypaimon.read.query_auth_split import resolve_auth_result
414+
fn = lambda select: TableQueryAuthResult(None, None)
415+
self.assertIsNone(resolve_auth_result(fn, None))
416+
417+
def test_empty_filter_returns_none(self):
418+
from pypaimon.read.query_auth_split import resolve_auth_result
419+
fn = lambda select: TableQueryAuthResult([], {})
420+
self.assertIsNone(resolve_auth_result(fn, None))
421+
422+
def test_blank_filter_stripped_returns_none(self):
423+
from pypaimon.read.query_auth_split import resolve_auth_result
424+
fn = lambda select: TableQueryAuthResult(["", None], None)
425+
self.assertIsNone(resolve_auth_result(fn, None))
426+
427+
def test_with_filter_returns_result(self):
428+
from pypaimon.read.query_auth_split import resolve_auth_result
429+
fn = lambda select: TableQueryAuthResult([_simple_filter_json()], None)
430+
result = resolve_auth_result(fn, None)
431+
self.assertIsNotNone(result)
432+
self.assertTrue(result.has_restrictions)
433+
434+
def test_with_masking_returns_result(self):
435+
from pypaimon.read.query_auth_split import resolve_auth_result
436+
fn = lambda select: TableQueryAuthResult(None, {"col": '{"name":"NULL"}'})
437+
result = resolve_auth_result(fn, None)
438+
self.assertIsNotNone(result)
439+
self.assertTrue(result.has_restrictions)
440+
369441

370442
class TestCoreOptionsQueryAuth(unittest.TestCase):
371443

0 commit comments

Comments
 (0)