This is an automated email from the ASF dual-hosted git repository.

xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new f60f17fd701 Reject cross-table functions under row-level security 
(#20169)
f60f17fd701 is described below

commit f60f17fd701cf326ad4e9d28735a11eee7de3d7b
Author: Xiang Fu <[email protected]>
AuthorDate: Fri Nov 6 17:41:30 2026 -0800

    Reject cross-table functions under row-level security (#20169)
    
    * Apply row-level security filters to the subquery of 
IN_PARTITIONED_SUBQUERY
    
    The broker ANDs the row filters of the principal into the WHERE clause of 
the
    query and stamps them in the query options, but the subquery of
    IN_PARTITIONED_SUBQUERY reaches the server as a string literal that carries
    neither. The server compiled that literal and ran it on the segments of the
    table as is, so the ID_SET it built covered rows the principal is not 
allowed
    to see: a principal with row filters could test whether values exist in 
other
    principals' rows, and enumerate them with GROUP BY, while the response still
    reported rlsFiltersApplied.
    
    Server: compile the literal to a PinotQuery, rename its data source to the
    queried table (the subquery runs on that table's segments whatever its FROM
    clause names), add the table's row filter to the options the subquery was
    written with and run RlsFiltersRewriter on it before converting it to a
    QueryContext. A subquery nested in the subquery is filtered the same way
    through the recursion. A query that carries row filters but none for the
    queried table fails instead of running the subquery unfiltered.
    
    Broker: reject the function with access denied when row filters apply to the
    table, before the row filters are added. The broker cannot tell a server of
    this release from an older one, so it fails closed instead of forwarding a
    subquery that an older server would run unfiltered. The new broker config
    pinot.broker.rls.reject.in.partitioned.subquery (default true) turns the
    rejection off once every server runs this release. Principals without row
    filters keep the function.
    
    Both sides match the function name through the new
    RequestUtils.isInPartitionedSubquery, the check the server already used
    (underscores removed, case folded per character), so a look-alike spelling 
the
    server resolves cannot slip past the broker.
    
    Upgrade matrix: new broker + old server rejects the query; old broker + new
    server filters the subquery through the rlsFilters-<table> option old 
brokers
    already send; new + new rejects by default. No wire or format change.
    
    Tests: QueryExecutorTest covers the server path, including a subquery whose
    FROM clause names another table and a nested subquery;
    BaseSingleStageBrokerRequestHandlerTest covers the detection under every
    spelling, the rejection, and the config that lifts it; RequestUtilsTest 
covers
    the matcher; RowLevelSecurityIntegrationTest covers the rejection end to 
end.
    The server and broker unit tests fail on the code before this change.
    
    * Always reject IN_PARTITIONED_SUBQUERY under row-level security
    
    Drop pinot.broker.rls.reject.in.partitioned.subquery: the broker never 
forwards
    the function for a principal with row filters, whatever the servers run. The
    server-side filter stays as defense in depth for an older broker.
    
    * Decide row-level security for every cross-table function in one place
    
    Add CrossTableFunction to pinot-common: the functions that read rows from
    somewhere other than the FROM clause (lookup, IN_SUBQUERY,
    IN_PARTITIONED_SUBQUERY) and the one spelling check they share, underscores
    dropped and case folded per character, as the server resolves them.
    
    DeleteStatement, the single-stage broker, the multi-stage IdSet subquery
    rewriter and the server executor all match through it instead of their own
    hand-spelled constants. The broker walks the query once for every 
cross-table
    call: the walk feeds the lookup() table collection used for table access and
    the row-filter check, whose exhaustive switch decides each kind (IN_SUBQUERY
    runs as its own request, lookup() is checked against the dimension table's
    own row filters, IN_PARTITIONED_SUBQUERY is rejected), so a new cross-table
    function cannot be added without deciding how row filters apply to it.
    
    * Require the subquery of IN_PARTITIONED_SUBQUERY to read the queried table 
under row filters
    
    Row filters belong to one table. Instead of renaming the subquery's data
    source to the queried table so that the outer table's filter applies to
    whatever the subquery names, the server now fails a subquery that names
    another table when row filters apply, and applies the table's own filter
    when it names the queried table. Without row filters nothing changes: the
    subquery still runs on the queried table's segments as it always has.
    
    * Cover the fail-closed branch for row filters stamped for another table
    
    QueryExecutorTest now checks that a query carrying row filters for another
    table only fails instead of running the subquery unfiltered, and the broker
    doc of the cross-table check states that it covers the physical-table path,
    since the logical-table path applies no row filters at all today.
    
    * Reject every cross-table function under row-level security
    
    The row filters are added to the WHERE clause of the queried table, which 
no cross-table function reads
    through. Rather than decide IN_SUBQUERY, lookup() and 
IN_PARTITIONED_SUBQUERY each on their own, the
    single-stage broker now rejects all of them with 403 for a principal with 
row filters, collecting them at
    compile time before IN_SUBQUERY calls are replaced by their results. The 
server fails IN_PARTITIONED_SUBQUERY
    closed when row filters are stamped, for a server behind an older broker, 
instead of filtering the subquery.
    
    * Replace the CrossTableFunction enum with a RequestUtils membership check
    
    Per review: RequestUtils.isCrossTableFunction(canonicalName), backed by a
    set of canonical names, replaces the enum and its per-function matches()
    API. The broker keeps a boolean in CompileResult, collected before the
    IN_SUBQUERY rewrite, and rejects with a generic message.
    
    The incidental refactors are reverted: the broker's handleSubquery and
    fixColumnName, IdSetSubqueryRewriter, the server's IN_PARTITIONED_SUBQUERY
    name check and extractLookupTableNames are back to their specific-function
    form, and DeleteStatement keeps its own list, cross-referenced. The
    server's row-filter guard is unchanged.
    
    * Inline the row-filter checks and gate the cross-table scan on row/column 
auth
    
    Per review: the broker's rejection and the server's IN_PARTITIONED_SUBQUERY
    guard each had one caller, so they sit at their call sites with a short
    comment, and the broker only scans the query for cross-table functions
    when row/column-level auth is enabled, since that is the only consumer.
    
    * Fold the cross-table function name through Locale.ROOT before the broker 
check
    
    The parser lower-cases the operator with the default locale, while an older
    server resolves IN_PARTITIONED_SUBQUERY with equalsIgnoreCase, so a dotless 
ı
    written in the query, or made of the I by a Turkish locale, passed the
    broker's membership check and ran the subquery unfiltered on a server
    without the row-filter guard. The broker now folds the name through
    Locale.ROOT in both directions, which matches what the server accepts,
    before the check; the dotless-ı case is back in the broker test, with a
    Turkish-locale case.
---
 .../BaseSingleStageBrokerRequestHandler.java       |  66 +++++++-
 .../BaseSingleStageBrokerRequestHandlerTest.java   | 166 +++++++++++++++++++++
 .../pinot/common/utils/request/RequestUtils.java   |  20 +++
 .../pinot/sql/parsers/dml/DeleteStatement.java     |   2 +
 .../common/utils/request/RequestUtilsTest.java     |  19 +++
 .../query/executor/ServerQueryExecutorV1Impl.java  |  24 ++-
 .../core/query/executor/QueryExecutorTest.java     |  44 ++++++
 .../tests/RowLevelSecurityIntegrationTest.java     |  32 ++++
 8 files changed, 363 insertions(+), 10 deletions(-)

diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandler.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandler.java
index 89aaeee8763..7265f7120ed 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandler.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandler.java
@@ -26,6 +26,7 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.BlockingQueue;
@@ -112,6 +113,7 @@ import org.apache.pinot.query.parser.utils.ParserUtils;
 import org.apache.pinot.spi.accounting.ThreadAccountant;
 import org.apache.pinot.spi.auth.AuthProvider;
 import org.apache.pinot.spi.auth.AuthorizationResult;
+import org.apache.pinot.spi.auth.BasicAuthorizationResultImpl;
 import org.apache.pinot.spi.auth.TableAuthorizationResult;
 import org.apache.pinot.spi.auth.TableRowColAccessResult;
 import org.apache.pinot.spi.auth.broker.RequesterIdentity;
@@ -407,16 +409,20 @@ public abstract class BaseSingleStageBrokerRequestHandler 
extends BaseBrokerRequ
     final String _rawTableName;
     /// Dimension tables read by `lookup()` calls, which are not part of the 
data source
     final Set<String> _lookupTableNames;
+    /// Whether the query calls a cross-table function as written, collected 
before `handleSubquery()` replaced its
+    /// `IN_SUBQUERY` calls with their results, for the row-filter rejection 
in `doHandleRequest()`
+    final boolean _hasCrossTableFunction;
     final BrokerResponse _errorOrLiteralOnlyBrokerResponse;
 
     public CompileResult(PinotQuery pinotQuery, PinotQuery serverPinotQuery, 
Schema schema, String tableName,
-        String rawTableName, Set<String> lookupTableNames) {
+        String rawTableName, Set<String> lookupTableNames, boolean 
hasCrossTableFunction) {
       _pinotQuery = pinotQuery;
       _serverPinotQuery = serverPinotQuery;
       _schema = schema;
       _tableName = tableName;
       _rawTableName = rawTableName;
       _lookupTableNames = lookupTableNames;
+      _hasCrossTableFunction = hasCrossTableFunction;
       _errorOrLiteralOnlyBrokerResponse = null;
     }
 
@@ -427,6 +433,7 @@ public abstract class BaseSingleStageBrokerRequestHandler 
extends BaseBrokerRequ
       _tableName = null;
       _rawTableName = null;
       _lookupTableNames = Set.of();
+      _hasCrossTableFunction = false;
       _errorOrLiteralOnlyBrokerResponse = errorOrLiteralOnlyBrokerResponse;
     }
   }
@@ -561,6 +568,16 @@ public abstract class BaseSingleStageBrokerRequestHandler 
extends BaseBrokerRequ
         TableRowColAccessResult rlsFilters = 
accessControl.getRowColFilters(requesterIdentity, tableName);
         List<String> rowFilters = rlsFilters.getRLSFilters().orElse(null);
         if (rowFilters != null && !rowFilters.isEmpty()) {
+          // The row filters cover the queried table only, while a cross-table 
function (lookup(), IN_SUBQUERY,
+          // IN_PARTITIONED_SUBQUERY) reads rows from somewhere else: 
IN_PARTITIONED_SUBQUERY runs its subquery on the
+          // server outside that WHERE clause and leaked the hidden rows. 
Reject them all rather than decide each one,
+          // so a new one is covered the day it is added. Physical tables 
only: the logical-table path above applies
+          // no row filters at all today.
+          if (compileResult._hasCrossTableFunction) {
+            throwAccessDeniedError(requestId, query, requestContext, tableName,
+                new BasicAuthorizationResultImpl(false, "Cross-table functions 
are not allowed on table "
+                    + rawTableName + " when row-level security filters 
apply"));
+          }
           /// Apply RLS to `serverPinotQuery` (the query the server actually 
executes), not to
           /// `pinotQuery` as the pre-MV codebase did — for gapfill queries 
`serverPinotQuery` is
           /// the inner stripped query and `pinotQuery` is the outer gapfill 
wrapper, so the pre-MV
@@ -1179,6 +1196,13 @@ public abstract class 
BaseSingleStageBrokerRequestHandler extends BaseBrokerRequ
       return new CompileResult(new 
BrokerResponseNative(QueryErrorCode.QUERY_VALIDATION, "Table name not found"));
     }
 
+    // Collected before handleSubquery() replaces each IN_SUBQUERY call with 
IN_ID_SET on its result, for
+    // doHandleRequest to reject cross-table functions once it knows whether 
row filters apply, and only consumed
+    // under that gate. For a gapfill query the two differ, and only the 
stripped one reaches a server.
+    boolean hasCrossTableFunction = _enableRowColumnLevelAuth
+        && (hasCrossTableFunction(serverPinotQuery)
+            || (serverPinotQuery != pinotQuery && 
hasCrossTableFunction(pinotQuery)));
+
     try {
       handleSubquery(serverPinotQuery, requestId, request, requesterIdentity, 
requestContext, httpHeaders,
           accessControl);
@@ -1257,7 +1281,8 @@ public abstract class BaseSingleStageBrokerRequestHandler 
extends BaseBrokerRequ
     Schema schema = _tableCache.getSchema(rawTableName);
     _queryOptimizer.optimize(serverPinotQuery, schema);
 
-    return new CompileResult(pinotQuery, serverPinotQuery, schema, tableName, 
rawTableName, lookupTableNames);
+    return new CompileResult(pinotQuery, serverPinotQuery, schema, tableName, 
rawTableName, lookupTableNames,
+        hasCrossTableFunction);
   }
 
   /// Mutable holder returned from [#applyMaterializedViewRewriteAtCompile] — 
Java has no out
@@ -2289,6 +2314,43 @@ public abstract class 
BaseSingleStageBrokerRequestHandler extends BaseBrokerRequ
     collectLookupTableNames(operands, lookupTableNames);
   }
 
+  /// Returns whether the query calls a cross-table function anywhere: in the 
select list, the filter, the having
+  /// clause and the group-by and order-by expressions, calls nested in other 
calls included. See
+  /// [RequestUtils#isCrossTableFunction(String)] for the functions.
+  @VisibleForTesting
+  static boolean hasCrossTableFunction(PinotQuery pinotQuery) {
+    return hasCrossTableFunction(pinotQuery.getSelectList())
+        || hasCrossTableFunction(pinotQuery.getFilterExpression())
+        || hasCrossTableFunction(pinotQuery.getHavingExpression())
+        || hasCrossTableFunction(pinotQuery.getGroupByList())
+        || hasCrossTableFunction(pinotQuery.getOrderByList());
+  }
+
+  private static boolean hasCrossTableFunction(@Nullable List<Expression> 
expressions) {
+    if (expressions != null) {
+      for (Expression expression : expressions) {
+        if (hasCrossTableFunction(expression)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  private static boolean hasCrossTableFunction(@Nullable Expression 
expression) {
+    if (expression == null || expression.getType() != ExpressionType.FUNCTION) 
{
+      return false;
+    }
+    Function functionCall = expression.getFunctionCall();
+    // The operator of a compiled call is canonical, see 
RequestUtils.canonicalizeFunctionName(), but lower-cased with
+    // the default locale, while the server resolves IN_PARTITIONED_SUBQUERY 
with equalsIgnoreCase(): fold it through
+    // Locale.ROOT so that a dotless ı (U+0131), written in the query or made 
of the I by a Turkish locale, and a long
+    // ſ (U+017F) are matched as the server matches them, or a server without 
the row-filter guard would run the
+    // subquery unfiltered behind this broker.
+    String canonicalName = 
functionCall.getOperator().toUpperCase(Locale.ROOT).toLowerCase(Locale.ROOT);
+    return RequestUtils.isCrossTableFunction(canonicalName) || 
hasCrossTableFunction(functionCall.getOperands());
+  }
+
   /// Fixes the column names to the actual column names in the given 
expression.
   private static void fixColumnName(String rawTableName, Expression 
expression, Map<String, String> columnNameMap,
       boolean ignoreCase) {
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandlerTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandlerTest.java
index deae4f9f903..c7864c5ab31 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandlerTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/BaseSingleStageBrokerRequestHandlerTest.java
@@ -24,6 +24,7 @@ import java.net.InetSocketAddress;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
@@ -33,6 +34,8 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 import javax.annotation.Nullable;
+import javax.ws.rs.WebApplicationException;
+import javax.ws.rs.core.Response;
 import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
 import org.apache.helix.model.InstanceConfig;
 import org.apache.pinot.broker.api.AccessControl;
@@ -97,14 +100,18 @@ import org.testng.annotations.AfterMethod;
 import org.testng.annotations.Test;
 
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
 
 
 public class BaseSingleStageBrokerRequestHandlerTest {
@@ -870,6 +877,165 @@ public class BaseSingleStageBrokerRequestHandlerTest {
         "Expected TOO_MANY_REQUESTS error code");
   }
 
+  @Test
+  public void testHasCrossTableFunction() {
+    String subquery = "SELECT ID_SET(col1) FROM myTable WHERE col2 = 2";
+    // The operator of a compiled call is canonical (underscores removed, 
lower-cased), so every spelling counts, the
+    // dotless ı (U+0131) look-alike that the server resolves too included
+    String[] spellings = {
+        "IN_PARTITIONED_SUBQUERY", "inpartitionedsubquery", 
"In_Partitioned_Sub_Query", "IN_PART\u0131TIONED_SUBQUERY"
+    };
+    for (String spelling : spellings) {
+      assertTrue(hasCrossTableFunction("SELECT COUNT(*) FROM myTable WHERE " + 
spelling + "(col1, '" + subquery
+          + "') = 1"), spelling);
+    }
+    // Nested in other predicates and expressions, and outside the WHERE clause
+    assertTrue(hasCrossTableFunction("SELECT COUNT(*) FROM myTable WHERE col2 
= 1 AND NOT (col3 > 2 OR "
+        + "IN_PARTITIONED_SUBQUERY(col1, '" + subquery + "') = 1)"));
+    assertTrue(hasCrossTableFunction("SELECT CASE WHEN 
IN_PARTITIONED_SUBQUERY(col1, '" + subquery
+        + "') = 1 THEN 1 ELSE 0 END FROM myTable"));
+    assertTrue(hasCrossTableFunction("SELECT col1, COUNT(*) FROM myTable GROUP 
BY col1 HAVING "
+        + "IN_PARTITIONED_SUBQUERY(col1, '" + subquery + "') = 1"));
+    // The other cross-table functions, alone and together, a lookup() nested 
in a lookup() included
+    assertTrue(hasCrossTableFunction("SELECT COUNT(*) FROM myTable WHERE 
IN_SUBQUERY(col1, '" + subquery
+        + "') = 1"));
+    assertTrue(hasCrossTableFunction("SELECT lookup('dimTable', 'name', 'id', 
col1) FROM myTable"));
+    assertTrue(hasCrossTableFunction("SELECT UPPER(lookup('dimTable', 'name', 
'id', col1)) FROM myTable"));
+    assertTrue(hasCrossTableFunction("SELECT lookup('dimTable', 'name', 'id', 
lookup('other', 'id', 'key', col1)) "
+        + "FROM myTable WHERE In_Sub_Query(col2, '" + subquery + "') = 1 ORDER 
BY LOOK_UP('third', 'v', 'k', col3)"));
+    assertTrue(hasCrossTableFunction("SELECT col1 FROM myTable ORDER BY 
LOOK_UP('dimTable', 'v', 'k', col1)"));
+    // Other functions, IN_ID_SET on a literal included, are not cross-table 
functions
+    assertFalse(hasCrossTableFunction("SELECT COUNT(*) FROM myTable WHERE 
IN_ID_SET(col1, 'AQ==') = 1"));
+    assertFalse(hasCrossTableFunction("SELECT COUNT(*) FROM myTable WHERE col1 
= 1"));
+    // Under a Turkish default locale the parser lower-cases the I of the 
plain spelling to a dotless ı as well
+    Locale defaultLocale = Locale.getDefault();
+    Locale.setDefault(Locale.forLanguageTag("tr-TR"));
+    try {
+      assertTrue(hasCrossTableFunction("SELECT COUNT(*) FROM myTable WHERE 
IN_PARTITIONED_SUBQUERY(col1, '" + subquery
+          + "') = 1"));
+    } finally {
+      Locale.setDefault(defaultLocale);
+    }
+  }
+
+  private static boolean hasCrossTableFunction(String sql) {
+    return 
BaseSingleStageBrokerRequestHandler.hasCrossTableFunction(CalciteSqlParser.compileToPinotQuery(sql));
+  }
+
+  /// The row filters are added to the WHERE clause of the queried table, 
which no cross-table function reads through,
+  /// so a principal with row filters is denied every one of them. 
`IN_SUBQUERY` runs a nested request that needs a
+  /// server response, so RowLevelSecurityIntegrationTest covers it and 
`testHasCrossTableFunction` its detection.
+  @Test
+  public void testCrossTableFunctionsRejectedWhenRowFiltersApply()
+      throws Exception {
+    String tableName = "myTable_OFFLINE";
+    TableCache tableCache = mock(TableCache.class);
+    TableConfig tableCfg = mock(TableConfig.class);
+    when(tableCache.getActualTableName(anyString())).thenReturn(tableName);
+    when(tableCache.getColumnNameMap(anyString())).thenReturn(Map.of("col1", 
"col1"));
+    when(tableCfg.getTenantConfig()).thenReturn(new 
TenantConfig("tier_BROKER", "tier_SERVER", null));
+    when(tableCache.getTableConfig(tableName)).thenReturn(tableCfg);
+    BrokerRoutingManager routingManager = mock(BrokerRoutingManager.class);
+    when(routingManager.routingExists(tableName)).thenReturn(true);
+    when(routingManager.getQueryTimeoutMs(tableName)).thenReturn(10000L);
+    RoutingTable rt = mock(RoutingTable.class);
+    when(rt.getServerInstanceToSegmentsMap()).thenReturn(
+        Map.of(new ServerInstance(new InstanceConfig("Server_localhost_9000")),
+            new SegmentsToQuery(List.of("segment01"), List.of())));
+    when(routingManager.getRoutingTable(any(), anyLong())).thenReturn(rt);
+    QueryQuotaManager queryQuotaManager = mock(QueryQuotaManager.class);
+    when(queryQuotaManager.acquire(anyString())).thenReturn(true);
+    when(queryQuotaManager.acquireDatabase(anyString())).thenReturn(true);
+    when(queryQuotaManager.acquireApplication(anyString())).thenReturn(true);
+
+    AtomicReference<List<String>> rowFilters = new 
AtomicReference<>(List.of("tenant = 'allowed'"));
+    AccessControl accessControl = new AccessControl() {
+      @Override
+      public AuthorizationResult authorize(RequesterIdentity identity, 
BrokerRequest request) {
+        return TableAuthorizationResult.success();
+      }
+
+      @Override
+      public TableAuthorizationResult authorize(RequesterIdentity identity, 
Set<String> tables) {
+        return TableAuthorizationResult.success();
+      }
+
+      @Override
+      public TableRowColAccessResult getRowColFilters(RequesterIdentity 
identity, String table) {
+        // Row filters on the queried table only: a dimension table with row 
filters is rejected by the lookup check
+        return new TableRowColAccessResultImpl(table.equals(tableName) ? 
rowFilters.get() : List.of());
+      }
+    };
+    AccessControlFactory accessControlFactory = 
mock(AccessControlFactory.class);
+    when(accessControlFactory.create()).thenReturn(accessControl);
+    BrokerMetrics.register(mock(BrokerMetrics.class));
+    PinotConfiguration config =
+        new 
PinotConfiguration(Map.of(Broker.CONFIG_OF_BROKER_ENABLE_ROW_COLUMN_LEVEL_AUTH, 
"true"));
+    BrokerQueryEventListenerFactory.init(config);
+    AtomicReference<BrokerRequest> serverRequest = new AtomicReference<>();
+    BaseSingleStageBrokerRequestHandler handler =
+        newServerRequestCapturingHandler(config, routingManager, 
accessControlFactory, queryQuotaManager, tableCache,
+            serverRequest);
+
+    Map<String, String> queries = Map.of(
+        "IN_PARTITIONED_SUBQUERY",
+        "SELECT COUNT(*) FROM myTable WHERE IN_PARTITIONED_SUBQUERY(col1, 
'SELECT ID_SET(col1) FROM myTable') = 1",
+        "lookup", "SELECT lookup('dimTable', 'dimValue', 'dimKey', col1) FROM 
myTable");
+    for (Map.Entry<String, String> entry : queries.entrySet()) {
+      String function = entry.getKey();
+      String query = entry.getValue();
+
+      // Rejected before anything reaches a server
+      rowFilters.set(List.of("tenant = 'allowed'"));
+      serverRequest.set(null);
+      WebApplicationException e = expectThrows(WebApplicationException.class, 
() -> handler.handleRequest(query));
+      assertEquals(e.getResponse().getStatus(), 
Response.Status.FORBIDDEN.getStatusCode(), function);
+      assertTrue(e.getMessage().contains("Cross-table functions are not 
allowed"), e.getMessage());
+      assertNull(serverRequest.get(), function);
+
+      // Allowed, and left for the server to resolve, when no row filter 
applies to the principal
+      rowFilters.set(List.of());
+      BrokerResponseNative response = (BrokerResponseNative) 
handler.handleRequest(query);
+      assertFalse(response.getRLSFiltersApplied(), function);
+      assertNotNull(serverRequest.get(), function);
+      
assertTrue(BaseSingleStageBrokerRequestHandler.hasCrossTableFunction(serverRequest.get().getPinotQuery()),
+          function);
+    }
+  }
+
+  /// A handler that records the request it would send to the servers instead 
of sending it.
+  private static BaseSingleStageBrokerRequestHandler 
newServerRequestCapturingHandler(PinotConfiguration config,
+      BrokerRoutingManager routingManager, AccessControlFactory 
accessControlFactory,
+      QueryQuotaManager queryQuotaManager, TableCache tableCache, 
AtomicReference<BrokerRequest> serverRequest) {
+    return new BaseSingleStageBrokerRequestHandler(config, "testBrokerId", new 
BrokerRequestIdGenerator(),
+        routingManager, accessControlFactory, queryQuotaManager, tableCache, 
ThreadAccountantUtils.getNoOpAccountant(),
+        null, null) {
+      @Override
+      public void start() {
+      }
+
+      @Override
+      public void shutDown() {
+      }
+
+      @Override
+      protected BrokerResponseNative processBrokerRequest(long requestId, 
BrokerRequest originalBrokerRequest,
+          BrokerRequest serverBrokerRequest, TableRouteInfo route, long 
timeoutMs, ServerStats serverStats,
+          RequestContext requestContext) {
+        serverRequest.set(serverBrokerRequest);
+        return BrokerResponseNative.empty();
+      }
+
+      @Override
+      protected BrokerResponseNative 
processMaterializedViewSplitBrokerRequest(long requestId,
+          long materializedViewRequestId, BrokerRequest originalBrokerRequest, 
TableRouteInfo baseRoute,
+          TableRouteInfo materializedViewRoute, long timeoutMs, ServerStats 
serverStats,
+          RequestContext requestContext) {
+        throw new UnsupportedOperationException("Not implemented in test");
+      }
+    };
+  }
+
   /// Bug: FULL_REWRITE overwrites tableName to the materialized view table 
name, so
   /// accessControl.getRowColFilters(requesterIdentity, tableName) fetches RLS
   /// policy for the materialized view table instead of the base table.
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/request/RequestUtils.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/request/RequestUtils.java
index 0f913137795..6f8623cc952 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/request/RequestUtils.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/request/RequestUtils.java
@@ -610,6 +610,26 @@ public class RequestUtils {
     return CANONICAL_NAME_TO_SPECIAL_KEY_MAP.getOrDefault(canonicalName, 
canonicalName);
   }
 
+  /// Canonical names, see [#canonicalizeFunctionName(String)], of the 
functions that read rows from somewhere other
+  /// than the FROM clause of the query that calls them: `lookup()` joins a 
dimension table by primary key,
+  /// `IN_SUBQUERY` runs its quoted subquery as a request of its own and 
`IN_PARTITIONED_SUBQUERY` runs it on the
+  /// server. The access checks keyed on the FROM clause do not cover them, so 
the single-stage broker rejects them
+  /// under row-level security, see [#isCrossTableFunction(String)].
+  ///
+  /// Nothing derives this set from the function registries, so a function 
that reads another table or runs a nested
+  /// query must be added here when it is defined, and to 
`DeleteStatement#CROSS_TABLE_FUNCTIONS`, which keeps the
+  /// same list in its own fold: `lookup` is `TransformFunctionType#LOOKUP` 
(with `LookupTransformFunction` in
+  /// pinot-core), `insubquery` is rewritten by 
`BaseSingleStageBrokerRequestHandler#IN_SUBQUERY` in pinot-broker and
+  /// `inpartitionedsubquery` by 
`ServerQueryExecutorV1Impl#IN_PARTITIONED_SUBQUERY` in pinot-core.
+  private static final Set<String> CROSS_TABLE_FUNCTIONS = Set.of("lookup", 
"insubquery", "inpartitionedsubquery");
+
+  /// Returns whether `canonicalName` names a function that reads rows from 
somewhere other than the FROM clause, see
+  /// [#CROSS_TABLE_FUNCTIONS]. The name must already be canonical, see 
[#canonicalizeFunctionName(String)], as the
+  /// parser leaves the operator of every compiled function call; a name as 
written in a query is not matched.
+  public static boolean isCrossTableFunction(String canonicalName) {
+    return CROSS_TABLE_FUNCTIONS.contains(canonicalName);
+  }
+
   /// Returns true iff `expression` is an `AS`-wrapped function call
   /// (i.e. shaped like `expr AS alias` after Calcite parsing).
   ///
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/dml/DeleteStatement.java
 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/dml/DeleteStatement.java
index a2f25f4f068..c0d01d324b4 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/dml/DeleteStatement.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/dml/DeleteStatement.java
@@ -145,6 +145,8 @@ public class DeleteStatement implements 
DataManipulationStatement {
   /// here when it is defined: `lookUp` is `TransformFunctionType#LOOKUP` 
(with `LookupTransformFunction` in
   /// pinot-core), `inSubquery` is rewritten by 
`BaseSingleStageBrokerRequestHandler#IN_SUBQUERY` in pinot-broker, and
   /// `inPartitionedSubquery` by 
`ServerQueryExecutorV1Impl#IN_PARTITIONED_SUBQUERY` in pinot-core.
+  /// `RequestUtils#isCrossTableFunction(String)` keeps the same list in lower 
case for the single-stage broker's
+  /// row-level security check; add the function there too.
   private static final Set<String> CROSS_TABLE_FUNCTIONS = Set.of("LOOKUP", 
"INSUBQUERY", "INPARTITIONEDSUBQUERY");
 
   /// Canonical names of the functions that run a script. Rejected because the 
broker's groovy policy
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/request/RequestUtilsTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/request/RequestUtilsTest.java
index a65148b3790..f151815bfe8 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/request/RequestUtilsTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/request/RequestUtilsTest.java
@@ -267,6 +267,25 @@ public class RequestUtilsTest {
 
   // ---------- helpers ----------
 
+  @Test
+  public void testIsCrossTableFunction() {
+    // The names the parser leaves on a compiled call, see 
RequestUtils.canonicalizeFunctionName()
+    String[] spellings = {
+        "LOOKUP", "look_Up", "IN_SUBQUERY", "inSubquery", 
"IN_PARTITIONED_SUBQUERY", "In_Partitioned_Sub_Query"
+    };
+    for (String spelling : spellings) {
+      
assertTrue(RequestUtils.isCrossTableFunction(RequestUtils.canonicalizeFunctionName(spelling)),
 spelling);
+    }
+    // The input must already be canonical: a name as written in a query is 
not matched
+    assertFalse(RequestUtils.isCrossTableFunction("IN_SUBQUERY"));
+    assertFalse(RequestUtils.isCrossTableFunction("inSubquery"));
+    // Other functions, IN_ID_SET on a literal included
+    String[] others = {"inidset", "idset", "lookups", "subquery", 
"inpartitionedsubquery2", "as", ""};
+    for (String other : others) {
+      assertFalse(RequestUtils.isCrossTableFunction(other), other);
+    }
+  }
+
   private static Expression sumOf(String column) {
     Function func = new Function("sum");
     func.setOperands(List.of(RequestUtils.getIdentifierExpression(column)));
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/executor/ServerQueryExecutorV1Impl.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/executor/ServerQueryExecutorV1Impl.java
index e0625491dd9..eacca5a1b9f 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/executor/ServerQueryExecutorV1Impl.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/executor/ServerQueryExecutorV1Impl.java
@@ -90,6 +90,7 @@ import org.apache.pinot.spi.trace.Tracer;
 import org.apache.pinot.spi.trace.Tracing;
 import org.apache.pinot.spi.utils.CommonConstants.Accounting.ScanKillingMode;
 import org.apache.pinot.spi.utils.CommonConstants.Server;
+import org.apache.pinot.sql.parsers.rewriter.RlsUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -511,7 +512,7 @@ public class ServerQueryExecutorV1Impl implements 
QueryExecutor {
       throws Exception {
     FilterContext filter = queryContext.getFilter();
     if (filter != null && !filter.isConstant()) {
-      handleSubquery(filter, tableExecutionInfo, timerContext, 
executorService, queryContext.getEndTimeMs());
+      handleSubquery(filter, tableExecutionInfo, timerContext, 
executorService, queryContext);
     }
   }
 
@@ -519,15 +520,15 @@ public class ServerQueryExecutorV1Impl implements 
QueryExecutor {
   ///
   /// Currently only supports subquery within the lhs of the predicate.
   private void handleSubquery(FilterContext filter, TableExecutionInfo 
executionInfo, TimerContext timerContext,
-      ExecutorService executorService, long endTimeMs)
+      ExecutorService executorService, QueryContext queryContext)
       throws Exception {
     List<FilterContext> children = filter.getChildren();
     if (children != null) {
       for (FilterContext child : children) {
-        handleSubquery(child, executionInfo, timerContext, executorService, 
endTimeMs);
+        handleSubquery(child, executionInfo, timerContext, executorService, 
queryContext);
       }
     } else {
-      handleSubquery(filter.getPredicate().getLhs(), executionInfo, 
timerContext, executorService, endTimeMs);
+      handleSubquery(filter.getPredicate().getLhs(), executionInfo, 
timerContext, executorService, queryContext);
     }
   }
 
@@ -537,9 +538,10 @@ public class ServerQueryExecutorV1Impl implements 
QueryExecutor {
   /// rewrites the expression with the subquery response.
   ///
   /// Currently only supports ID_SET subquery within the 
IN_PARTITIONED_SUBQUERY transform function, which will be
-  /// rewritten to an IN_ID_SET transform function.
+  /// rewritten to an IN_ID_SET transform function. The function is not 
allowed under row-level security: the broker
+  /// rejects it, and a server behind an older broker fails the query.
   private void handleSubquery(ExpressionContext expression, TableExecutionInfo 
executionInfo, TimerContext timerContext,
-      ExecutorService executorService, long endTimeMs)
+      ExecutorService executorService, QueryContext queryContext)
       throws Exception {
     FunctionContext function = expression.getFunction();
     if (function == null) {
@@ -547,6 +549,12 @@ public class ServerQueryExecutorV1Impl implements 
QueryExecutor {
     }
     List<ExpressionContext> arguments = function.getArguments();
     if (StringUtils.remove(function.getFunctionName(), 
'_').equalsIgnoreCase(IN_PARTITIONED_SUBQUERY)) {
+      // Reject under RLS even when an older broker allows the function 
through: the subquery is an opaque string
+      // literal the row filters stamped in the options do not reach, so it 
would build its ID_SET from every row
+      Map<String, String> rowFilters = 
RlsUtils.extractRlsFilters(queryContext.getQueryOptions());
+      Preconditions.checkState(rowFilters.isEmpty(),
+          "IN_PARTITIONED_SUBQUERY is not allowed when row-level security 
filters apply, got filters for %s",
+          rowFilters.keySet());
       Preconditions.checkArgument(arguments.size() == 2,
           "IN_PARTITIONED_SUBQUERY requires 2 arguments: expression, 
subquery");
       ExpressionContext subqueryExpression = arguments.get(1);
@@ -563,7 +571,7 @@ public class ServerQueryExecutorV1Impl implements 
QueryExecutor {
           "Subquery in IN_PARTITIONED_SUBQUERY should be an ID_SET aggregation 
only query, found: %s",
           subqueryExpression.toString());
       // Execute the subquery
-      subquery.setEndTimeMs(endTimeMs);
+      subquery.setEndTimeMs(queryContext.getEndTimeMs());
       // Make a clone of indexSegments because the method might modify the list
       InstanceResponseBlock instanceResponse =
           executeInternal(executionInfo, subquery, timerContext, 
executorService, null, false, null);
@@ -579,7 +587,7 @@ public class ServerQueryExecutorV1Impl implements 
QueryExecutor {
       arguments.set(1, 
ExpressionContext.forLiteral(RequestUtils.getLiteral(((IdSet) 
result).toBase64String())));
     } else {
       for (ExpressionContext argument : arguments) {
-        handleSubquery(argument, executionInfo, timerContext, executorService, 
endTimeMs);
+        handleSubquery(argument, executionInfo, timerContext, executorService, 
queryContext);
       }
     }
   }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/executor/QueryExecutorTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/executor/QueryExecutorTest.java
index 61965252a8a..205b756ff49 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/query/executor/QueryExecutorTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/executor/QueryExecutorTest.java
@@ -35,6 +35,7 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
+import javax.annotation.Nullable;
 import org.apache.commons.configuration2.PropertiesConfiguration;
 import org.apache.commons.io.FileUtils;
 import org.apache.helix.HelixManager;
@@ -78,6 +79,7 @@ import org.apache.pinot.spi.utils.ReadMode;
 import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
 import org.apache.pinot.spi.utils.builder.TableNameBuilder;
 import org.apache.pinot.sql.parsers.CalciteSqlCompiler;
+import org.apache.pinot.sql.parsers.rewriter.RlsUtils;
 import org.apache.pinot.tsdb.spi.AggInfo;
 import org.apache.pinot.tsdb.spi.TimeBuckets;
 import org.apache.pinot.tsdb.spi.series.SimpleTimeSeriesBuilderFactory;
@@ -231,6 +233,48 @@ public class QueryExecutorTest {
     assertEquals(((AggregationResultsBlock) 
instanceResponse.getResultsBlock()).getResults().get(0), 0.0);
   }
 
+  /// The broker rejects IN_PARTITIONED_SUBQUERY for a principal with row 
filters. A server behind an older broker,
+  /// which stamps the row filters in the query options but lets the function 
through, fails the query instead: the
+  /// subquery reaches it as a string literal the filters do not cover, so it 
would build its ID_SET from every row.
+  @Test
+  public void testInPartitionedSubqueryRejectedUnderRowFilters() {
+    String query = "SELECT COUNT(*) FROM " + OFFLINE_TABLE_NAME + " WHERE 
IN_PARTITIONED_SUBQUERY(orderItemCount, "
+        + "'SELECT ID_SET(orderItemCount) FROM " + OFFLINE_TABLE_NAME + "') = 
1";
+    // Without row filters the function resolves as it always has: every row's 
value is in the ID_SET
+    long numRows = (Long) aggregate("SELECT COUNT(*) FROM " + 
OFFLINE_TABLE_NAME, null);
+    assertEquals(aggregate(query, null), numRows);
+
+    // With row filters stamped for any table the query fails before the 
subquery runs
+    for (String table : new String[]{RAW_TABLE_NAME, "otherTable"}) {
+      Map<String, String> queryOptions = 
Map.of(RlsUtils.buildRlsFilterKey(table), "orderItemCount > 0");
+      String error = executeExpectingFailure(query, queryOptions);
+      assertTrue(error.contains("not allowed when row-level security filters 
apply"), error);
+    }
+  }
+
+  private Object aggregate(String query, @Nullable Map<String, String> 
queryOptions) {
+    InstanceResponseBlock instanceResponse = execute(newInstanceRequest(query, 
queryOptions));
+    assertTrue(instanceResponse.getExceptions().isEmpty(), 
instanceResponse.getExceptions().toString());
+    assertTrue(instanceResponse.getResultsBlock() instanceof 
AggregationResultsBlock);
+    return ((AggregationResultsBlock) 
instanceResponse.getResultsBlock()).getResults().get(0);
+  }
+
+  /// Runs the query and returns the text of the exceptions it fails with.
+  private String executeExpectingFailure(String query, @Nullable Map<String, 
String> queryOptions) {
+    InstanceResponseBlock instanceResponse = execute(newInstanceRequest(query, 
queryOptions));
+    assertFalse(instanceResponse.getExceptions().isEmpty(), "Expected the 
query to fail");
+    return String.join("\n", instanceResponse.getExceptions().values());
+  }
+
+  private InstanceRequest newInstanceRequest(String query, @Nullable 
Map<String, String> queryOptions) {
+    InstanceRequest instanceRequest = new InstanceRequest(0L, 
CalciteSqlCompiler.compileToBrokerRequest(query));
+    if (queryOptions != null) {
+      instanceRequest.getQuery().getPinotQuery().setQueryOptions(new 
HashMap<>(queryOptions));
+    }
+    instanceRequest.setSearchSegments(_segmentNames);
+    return instanceRequest;
+  }
+
   @Test
   public void testNoRemoteAccessStatsWithoutRemoteAccesses() {
     String query = "SELECT COUNT(*) FROM " + OFFLINE_TABLE_NAME;
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/RowLevelSecurityIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/RowLevelSecurityIntegrationTest.java
index 663c528d14e..539074177c5 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/RowLevelSecurityIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/RowLevelSecurityIntegrationTest.java
@@ -41,8 +41,10 @@ import static 
org.apache.pinot.integration.tests.BasicAuthTestUtils.AUTH_HEADER;
 import static 
org.apache.pinot.integration.tests.BasicAuthTestUtils.AUTH_HEADER_USER;
 import static org.apache.pinot.integration.tests.BasicAuthTestUtils.AUTH_TOKEN;
 import static 
org.apache.pinot.integration.tests.ClusterIntegrationTestUtils.getBrokerQueryApiUrl;
+import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.fail;
 
 
 public class RowLevelSecurityIntegrationTest extends 
BaseClusterIntegrationTest {
@@ -278,6 +280,36 @@ public class RowLevelSecurityIntegrationTest extends 
BaseClusterIntegrationTest
         queryBroker(multiStageQuery, AUTH_HEADER_USER_2)));
   }
 
+  /// The row filters are added to the WHERE clause of the queried table, 
which no cross-table function reads through:
+  /// the server resolves IN_PARTITIONED_SUBQUERY by running the quoted 
subquery on the segments of the table, where
+  /// the filters do not reach it, so the subquery below would see the rows of 
every other airline. The broker rejects
+  /// every cross-table function for a principal with row filters, IN_SUBQUERY 
included although its nested request
+  /// is filtered on its own.
+  @Test
+  public void testCrossTableFunctionsRejectedForPrincipalWithRowFilters()
+      throws Exception {
+    setUseMultiStageQueryEngine(false);
+    long numRows = queryBroker("SELECT COUNT(*) FROM mytable", 
AUTH_HEADER).get("resultTable").get("rows").get(0)
+        .get(0).asLong();
+    for (String function : new String[]{"IN_PARTITIONED_SUBQUERY", 
"IN_SUBQUERY"}) {
+      String query = "SELECT COUNT(*) FROM mytable WHERE " + function + 
"(AirlineID - AirlineID, "
+          + "'SELECT ID_SET(AirlineID - AirlineID) FROM mytable WHERE 
AirlineID <> 19805') = 1";
+
+      // A principal without row filters keeps the function
+      JsonNode adminResponse = queryBroker(query, AUTH_HEADER);
+      assertFalse(adminResponse.get("rlsFiltersApplied").asBoolean(), 
function);
+      
assertEquals(adminResponse.get("resultTable").get("rows").get(0).get(0).asLong(),
 numRows, function);
+
+      // A principal with row filters is rejected
+      try {
+        JsonNode response = queryBroker(query, AUTH_HEADER_USER);
+        fail("Expected " + function + " to be rejected, got: " + response);
+      } catch (IOException e) {
+        assertTrue(e.getMessage().contains("Cross-table functions are not 
allowed"), e.getMessage());
+      }
+    }
+  }
+
   private JsonNode queryBroker(String query, Map<String, String> headers)
       throws Exception {
     JsonNode response =


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to