Skip to content

Commit 41ee76b

Browse files
authored
Fix UnsupportedOperationException when RLS filter combines with expression override (#19008)
1 parent f216ed2 commit 41ee76b

4 files changed

Lines changed: 182 additions & 3 deletions

File tree

pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandlerTest.java

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
package org.apache.pinot.broker.requesthandler;
2020

2121
import java.util.HashMap;
22+
import java.util.HashSet;
2223
import java.util.List;
2324
import java.util.Map;
2425
import java.util.Set;
@@ -54,8 +55,11 @@
5455
import org.apache.pinot.materializedview.rewrite.MaterializedViewQueryRewriteEngine;
5556
import org.apache.pinot.materializedview.rewrite.MaterializedViewRewritePlan;
5657
import org.apache.pinot.spi.accounting.ThreadAccountantUtils;
58+
import org.apache.pinot.spi.auth.AuthorizationResult;
5759
import org.apache.pinot.spi.auth.TableAuthorizationResult;
60+
import org.apache.pinot.spi.auth.TableRowColAccessResult;
5861
import org.apache.pinot.spi.auth.TableRowColAccessResultImpl;
62+
import org.apache.pinot.spi.auth.broker.RequesterIdentity;
5963
import org.apache.pinot.spi.config.table.TableConfig;
6064
import org.apache.pinot.spi.config.table.TenantConfig;
6165
import org.apache.pinot.spi.data.FieldSpec.DataType;
@@ -857,6 +861,146 @@ protected BrokerResponseNative processMaterializedViewSplitBrokerRequest(long re
857861
/// org.apache.pinot.materializedview.handler.DefaultMaterializedViewHandler#attachFilter; their
858862
/// regression coverage lives in DefaultMaterializedViewHandlerTest in pinot-materialized-view.
859863

864+
/// Bug: `RlsFiltersRewriter` combines the RLS predicate with an existing WHERE clause via
865+
/// `RequestUtils.getFunctionExpression(AND, List.of(...))`. The `List<Expression>` overload used
866+
/// to store the list as-is, so the AND function's operand list was the immutable list returned by
867+
/// `List.of(...)`.
868+
///
869+
/// When the table also carries an expression-override map, `handleExpressionOverride` recurses
870+
/// into the filter tree and calls `function.getOperands().replaceAll(...)` on that AND function,
871+
/// throwing `UnsupportedOperationException` on the immutable operand list.
872+
///
873+
/// This test reproduces the crash by enabling RLS (which yields a combined AND filter) on a query
874+
/// that already has a WHERE clause, for a table whose expression-override map is non-null. Before
875+
/// the fix, `handleRequest` throws `UnsupportedOperationException`; after the fix it completes and
876+
/// reports that RLS filters were applied.
877+
@Test
878+
public void testRlsFilterCombinedWithExpressionOverrideDoesNotThrow()
879+
throws Exception {
880+
String rawTable = "faroEvents";
881+
String offlineTable = "faroEvents_OFFLINE";
882+
883+
// Query with an existing WHERE clause so the RLS rewriter builds a combined AND filter.
884+
String userSql = "SELECT COUNT(*) FROM faroEvents WHERE ts = 'x'";
885+
886+
Schema schema = new Schema.SchemaBuilder()
887+
.setSchemaName(rawTable)
888+
.addSingleValueDimension("ts", DataType.STRING)
889+
.addMetric("revenue", DataType.DOUBLE)
890+
.build();
891+
892+
TableCache tableCache = mock(TableCache.class);
893+
when(tableCache.getActualTableName(rawTable)).thenReturn(rawTable);
894+
when(tableCache.getSchema(rawTable)).thenReturn(schema);
895+
when(tableCache.getColumnNameMap(anyString())).thenReturn(Map.of("ts", "ts", "revenue", "revenue"));
896+
TableConfig tableCfg = mock(TableConfig.class);
897+
when(tableCfg.getTenantConfig()).thenReturn(new TenantConfig("t_BROKER", "t_SERVER", null));
898+
when(tableCache.getTableConfig(offlineTable)).thenReturn(tableCfg);
899+
900+
// Non-null expression-override map for the table. A non-null map is enough to make
901+
// handleExpressionOverride recurse into the filter tree and call replaceAll on the AND operands.
902+
Expression overrideKey = CalciteSqlParser.compileToExpression("ts");
903+
Expression overrideValue = CalciteSqlParser.compileToExpression("ts");
904+
when(tableCache.getExpressionOverrideMap(offlineTable)).thenReturn(Map.of(overrideKey, overrideValue));
905+
906+
BrokerRoutingManager routingManager = mock(BrokerRoutingManager.class);
907+
when(routingManager.routingExists(offlineTable)).thenReturn(true);
908+
when(routingManager.getQueryTimeoutMs(anyString())).thenReturn(10000L);
909+
RoutingTable rt = mock(RoutingTable.class);
910+
when(rt.getServerInstanceToSegmentsMap()).thenReturn(
911+
Map.of(new ServerInstance(new InstanceConfig("server01_9000")),
912+
new SegmentsToQuery(List.of("seg01"), List.of())));
913+
when(routingManager.getRoutingTable(any(), Mockito.anyLong())).thenReturn(rt);
914+
915+
QueryQuotaManager quotaManager = mock(QueryQuotaManager.class);
916+
when(quotaManager.acquire(anyString())).thenReturn(true);
917+
when(quotaManager.acquireDatabase(anyString())).thenReturn(true);
918+
when(quotaManager.acquireApplication(anyString())).thenReturn(true);
919+
920+
// AccessControl that allows everything and returns a non-empty RLS filter for the table.
921+
AccessControl accessControl = new AccessControl() {
922+
@Override
923+
public AuthorizationResult authorize(RequesterIdentity identity, BrokerRequest request) {
924+
return TableAuthorizationResult.success();
925+
}
926+
927+
@Override
928+
public TableAuthorizationResult authorize(RequesterIdentity identity, Set<String> tables) {
929+
return TableAuthorizationResult.success();
930+
}
931+
932+
@Override
933+
public TableRowColAccessResult getRowColFilters(RequesterIdentity identity, String tableWithType) {
934+
return new TableRowColAccessResultImpl(List.of("ts = 'allowed'"));
935+
}
936+
};
937+
AccessControlFactory accessControlFactory = mock(AccessControlFactory.class);
938+
when(accessControlFactory.create()).thenReturn(accessControl);
939+
940+
BrokerMetrics.register(mock(BrokerMetrics.class));
941+
PinotConfiguration config = new PinotConfiguration(
942+
Map.of(Broker.CONFIG_OF_BROKER_ENABLE_ROW_COLUMN_LEVEL_AUTH, "true"));
943+
BrokerQueryEventListenerFactory.init(config);
944+
945+
AtomicReference<BrokerRequest> capturedServerRequest = new AtomicReference<>();
946+
BaseSingleStageBrokerRequestHandler handler =
947+
new BaseSingleStageBrokerRequestHandler(config, "broker1", new BrokerRequestIdGenerator(),
948+
routingManager, accessControlFactory, quotaManager, tableCache,
949+
ThreadAccountantUtils.getNoOpAccountant(), null) {
950+
@Override
951+
public void start() {
952+
}
953+
954+
@Override
955+
public void shutDown() {
956+
}
957+
958+
@Override
959+
protected BrokerResponseNative processBrokerRequest(long requestId,
960+
BrokerRequest originalBrokerRequest, BrokerRequest serverBrokerRequest,
961+
TableRouteInfo route, long timeoutMs, ServerStats serverStats,
962+
RequestContext requestContext) {
963+
capturedServerRequest.set(serverBrokerRequest);
964+
return BrokerResponseNative.empty();
965+
}
966+
};
967+
968+
// Before the fix this throws UnsupportedOperationException from replaceAll on the immutable
969+
// AND operand list produced by the RLS rewriter.
970+
BrokerResponseNative response = (BrokerResponseNative) handler.handleRequest(userSql);
971+
972+
Assert.assertNotNull(response, "handleRequest must return a response, not throw");
973+
Assert.assertTrue(response.getRLSFiltersApplied(), "response should report that RLS filters were applied");
974+
975+
// The server query must carry BOTH the RLS predicate and the original WHERE clause, combined
976+
// under a single AND. This guards against a future regression that silently drops an operand.
977+
BrokerRequest serverRequest = capturedServerRequest.get();
978+
Assert.assertNotNull(serverRequest, "server query should have been captured");
979+
Expression filter = serverRequest.getPinotQuery().getFilterExpression();
980+
Assert.assertNotNull(filter, "server query must have a filter expression");
981+
Function filterFunction = filter.getFunctionCall();
982+
Assert.assertNotNull(filterFunction, "combined filter must be a function");
983+
Assert.assertEquals(filterFunction.getOperator(), FilterKind.AND.name(),
984+
"RLS predicate and existing WHERE clause must be combined under AND");
985+
Assert.assertEquals(filterFunction.getOperands().size(), 2, "combined AND must retain both operands");
986+
// Collect the RHS string literals of both EQUALS operands: one must be the RLS predicate value
987+
// ('allowed') and the other the original WHERE-clause value ('x').
988+
Set<String> literalValues = new HashSet<>();
989+
for (Expression operand : filterFunction.getOperands()) {
990+
Function eq = operand.getFunctionCall();
991+
Assert.assertNotNull(eq, "each AND operand must be a comparison function");
992+
for (Expression eqOperand : eq.getOperands()) {
993+
if (eqOperand.getLiteral() != null) {
994+
literalValues.add(eqOperand.getLiteral().getStringValue());
995+
}
996+
}
997+
}
998+
Assert.assertTrue(literalValues.contains("allowed"),
999+
"combined filter must include the RLS predicate, got literals: " + literalValues);
1000+
Assert.assertTrue(literalValues.contains("x"),
1001+
"combined filter must retain the original WHERE clause, got literals: " + literalValues);
1002+
}
1003+
8601004
/**
8611005
* Pins the security-style defense that a user-supplied `materializedViewRewrite=true` query
8621006
* option (e.g. via `SET materializedViewRewrite='true'`) is stripped at the broker entry

pinot-common/src/main/java/org/apache/pinot/common/utils/request/RequestUtils.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,9 +487,16 @@ public static String getLiteralString(Expression expression) {
487487
return getLiteralString(literal);
488488
}
489489

490+
/// Creates a `Function` with the given operands. The operand list stored in the returned function
491+
/// is always a mutable `ArrayList`: if `operands` is not already an `ArrayList` (e.g. an immutable
492+
/// `List.of(...)`), it is copied into one. Downstream query rewriters and filter optimizers mutate
493+
/// operands in place (via `getOperands().replaceAll(...)`, `set(...)`, or `add(...)`), so an
494+
/// immutable list would otherwise throw `UnsupportedOperationException` far from where it was
495+
/// created.
490496
public static Function getFunction(String canonicalName, List<Expression> operands) {
491497
Function function = new Function(canonicalName);
492-
function.setOperands(operands);
498+
// Ensure a mutable ArrayList so downstream rewriters can modify operands in place.
499+
function.setOperands(operands instanceof ArrayList ? operands : new ArrayList<>(operands));
493500
return function;
494501
}
495502

pinot-common/src/main/java/org/apache/pinot/sql/parsers/rewriter/RlsFiltersRewriter.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
*/
1919
package org.apache.pinot.sql.parsers.rewriter;
2020

21-
import java.util.List;
2221
import java.util.Map;
2322
import org.apache.commons.collections4.MapUtils;
2423
import org.apache.logging.log4j.util.Strings;
@@ -69,7 +68,7 @@ public PinotQuery rewrite(PinotQuery pinotQuery) {
6968
Expression existingFilterExpression = pinotQuery.getFilterExpression();
7069
if (existingFilterExpression != null) {
7170
Expression combinedFilterExpression =
72-
RequestUtils.getFunctionExpression(FilterKind.AND.name(), List.of(expression, existingFilterExpression));
71+
RequestUtils.getFunctionExpression(FilterKind.AND.name(), expression, existingFilterExpression);
7372
pinotQuery.setFilterExpression(combinedFilterExpression);
7473
} else {
7574
pinotQuery.setFilterExpression(expression);

pinot-common/src/test/java/org/apache/pinot/common/utils/request/RequestUtilsTest.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,35 @@ public void testNullLiteralParsing() {
5252
assertTrue(nullExpr.getLiteral().getNullValue());
5353
}
5454

55+
/// Pins the factory contract that `RequestUtils.getFunction` always returns a function whose
56+
/// operand list is mutable, even when the caller passes an immutable list. Downstream query
57+
/// rewriters and filter optimizers mutate operands in place; a regression here would resurface as
58+
/// an `UnsupportedOperationException` far from this factory (e.g. the broker RLS +
59+
/// expression-override path).
60+
@Test
61+
public void testGetFunctionReturnsMutableOperandList() {
62+
Expression a = RequestUtils.getLiteralExpression(1L);
63+
Expression b = RequestUtils.getLiteralExpression(2L);
64+
65+
// List overload with a genuinely immutable input list must be defensively copied.
66+
Function fromList = RequestUtils.getFunction("and", List.of(a, b));
67+
fromList.getOperands().replaceAll(o -> o);
68+
fromList.getOperands().set(0, b);
69+
fromList.getOperands().add(a);
70+
assertEquals(fromList.getOperands().size(), 3);
71+
72+
// Varargs and single-operand overloads delegate to the List overload and share the guarantee.
73+
Function fromVarargs = RequestUtils.getFunction("and", a, b);
74+
fromVarargs.getOperands().replaceAll(o -> o);
75+
fromVarargs.getOperands().add(b);
76+
assertEquals(fromVarargs.getOperands().size(), 3);
77+
78+
Function fromSingle = RequestUtils.getFunction("not", a);
79+
fromSingle.getOperands().replaceAll(o -> o);
80+
fromSingle.getOperands().add(b);
81+
assertEquals(fromSingle.getOperands().size(), 2);
82+
}
83+
5584
@Test
5685
public void testGetLiteralExpressionForPrimitiveLong() {
5786
Expression literalExpression = RequestUtils.getLiteralExpression(4500L);

0 commit comments

Comments
 (0)