You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pinot.apache.org by ya...@apache.org on 2026/08/11 16:27:19 UTC

(pinot) branch master updated: Apply table access control to the dimension table read by LOOKUP() in SSE (#19209)

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

yashmayya 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 3eaa39a655e Apply table access control to the dimension table read by LOOKUP() in SSE (#19209)
3eaa39a655e is described below

commit 3eaa39a655e52b97a5b7f1bd907aad20c6d3c6ea
Author: Yash Mayya <ya...@gmail.com>
AuthorDate: Tue Aug 11 12:27:12 2026 -0400

    Apply table access control to the dimension table read by LOOKUP() in SSE (#19209)
---
 .../BaseSingleStageBrokerRequestHandler.java       |  90 ++++++-
 .../BaseSingleStageBrokerRequestHandlerTest.java   |  37 +++
 .../tests/TableAccessControlIntegrationTest.java   | 263 +++++++++++++++++++++
 3 files changed, 387 insertions(+), 3 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 b7603747389..94fa3709686 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
@@ -108,6 +108,7 @@ import org.apache.pinot.materializedview.rewrite.MaterializedViewRewritePlan;
 import org.apache.pinot.query.parser.utils.ParserUtils;
 import org.apache.pinot.spi.accounting.ThreadAccountant;
 import org.apache.pinot.spi.auth.AuthorizationResult;
+import org.apache.pinot.spi.auth.TableAuthorizationResult;
 import org.apache.pinot.spi.auth.TableRowColAccessResult;
 import org.apache.pinot.spi.auth.broker.RequesterIdentity;
 import org.apache.pinot.spi.config.table.FieldConfig;
@@ -148,6 +149,9 @@ public abstract class BaseSingleStageBrokerRequestHandler extends BaseBrokerRequ
   private static final Expression FALSE = RequestUtils.getLiteralExpression(false);
   private static final Expression TRUE = RequestUtils.getLiteralExpression(true);
   private static final Expression STAR = RequestUtils.getIdentifierExpression("*");
+  /// Canonical name (see [RequestUtils#canonicalizeFunctionName]) of the `lookup()` transform function, which reads a
+  /// dimension table named by a string literal argument instead of by the FROM clause.
+  private static final String LOOKUP_FUNCTION = "lookup";
   private static final int MAX_UNAVAILABLE_SEGMENTS_TO_PRINT_IN_QUERY_EXCEPTION = 10;
 
   protected final QueryOptimizer _queryOptimizer = new QueryOptimizer();
@@ -398,15 +402,18 @@ public abstract class BaseSingleStageBrokerRequestHandler extends BaseBrokerRequ
     final Schema _schema;
     final String _tableName;
     final String _rawTableName;
+    /// Dimension tables read by `lookup()` calls, which are not part of the data source
+    final Set<String> _lookupTableNames;
     final BrokerResponse _errorOrLiteralOnlyBrokerResponse;
 
     public CompileResult(PinotQuery pinotQuery, PinotQuery serverPinotQuery, Schema schema, String tableName,
-        String rawTableName) {
+        String rawTableName, Set<String> lookupTableNames) {
       _pinotQuery = pinotQuery;
       _serverPinotQuery = serverPinotQuery;
       _schema = schema;
       _tableName = tableName;
       _rawTableName = rawTableName;
+      _lookupTableNames = lookupTableNames;
       _errorOrLiteralOnlyBrokerResponse = null;
     }
 
@@ -416,6 +423,7 @@ public abstract class BaseSingleStageBrokerRequestHandler extends BaseBrokerRequ
       _schema = null;
       _tableName = null;
       _rawTableName = null;
+      _lookupTableNames = Set.of();
       _errorOrLiteralOnlyBrokerResponse = errorOrLiteralOnlyBrokerResponse;
     }
   }
@@ -481,6 +489,30 @@ public abstract class BaseSingleStageBrokerRequestHandler extends BaseBrokerRequ
     BrokerRequest serverBrokerRequest =
         serverPinotQuery == pinotQuery ? brokerRequest : CalciteSqlCompiler.convertToBrokerRequest(serverPinotQuery);
 
+    // A `lookup()` dimension table is not part of the data source, so neither the logical-table check nor the
+    // BrokerRequest based check below covers it. Authorize it here, for both branches.
+    Set<String> lookupTableNames = compileResult._lookupTableNames;
+    if (!lookupTableNames.isEmpty()) {
+      AuthorizationResult lookupAuthorizationResult =
+          hasTableAccess(requesterIdentity, lookupTableNames, requestContext, httpHeaders);
+      if (!lookupAuthorizationResult.hasAccess()) {
+        throwAccessDeniedError(requestId, query, requestContext, tableName, lookupAuthorizationResult);
+      }
+      if (_enableRowColumnLevelAuth) {
+        // `lookup()` resolves a row by primary key against the dimension table's in-memory data and never evaluates a
+        // filter against that table, so an RLS filter on it cannot be applied. Reject the query instead of returning
+        // rows the principal is not allowed to see.
+        for (String lookupTableName : lookupTableNames) {
+          List<String> rowFilters =
+              accessControl.getRowColFilters(requesterIdentity, lookupTableName).getRLSFilters().orElse(null);
+          if (rowFilters != null && !rowFilters.isEmpty()) {
+            throwAccessDeniedError(requestId, query, requestContext, tableName,
+                new TableAuthorizationResult(Set.of(lookupTableName)));
+          }
+        }
+      }
+    }
+
     TableRouteProvider routeProvider;
 
     AtomicBoolean rlsFiltersApplied = new AtomicBoolean(false);
@@ -1171,6 +1203,14 @@ public abstract class BaseSingleStageBrokerRequestHandler extends BaseBrokerRequ
       throwAccessDeniedError(requestId, query, requestContext, tableName, authorizationResult);
     }
 
+    // A `lookup()` dimension table is named by a literal argument rather than by the FROM clause, so it is absent from
+    // the data source. Collect it here so that `doHandleRequest` can authorize it alongside the queried table.
+    Set<String> lookupTableNames = extractLookupTableNames(serverPinotQuery);
+    if (serverPinotQuery != pinotQuery) {
+      // For a gapfill query the two differ, and only the stripped one was walked above
+      lookupTableNames.addAll(extractLookupTableNames(pinotQuery));
+    }
+
     try {
       Map<String, String> columnNameMap = _tableCache.getColumnNameMap(rawTableName);
       if (columnNameMap != null) {
@@ -1205,7 +1245,7 @@ public abstract class BaseSingleStageBrokerRequestHandler extends BaseBrokerRequ
     Schema schema = _tableCache.getSchema(rawTableName);
     _queryOptimizer.optimize(serverPinotQuery, schema);
 
-    return new CompileResult(pinotQuery, serverPinotQuery, schema, tableName, rawTableName);
+    return new CompileResult(pinotQuery, serverPinotQuery, schema, tableName, rawTableName, lookupTableNames);
   }
 
   /// Mutable holder returned from [#applyMaterializedViewRewriteAtCompile] — Java has no out
@@ -2152,6 +2192,50 @@ public abstract class BaseSingleStageBrokerRequestHandler extends BaseBrokerRequ
     pinotQuery.setSelectList(newSelections);
   }
 
+  /// Returns the dimension tables read by the `lookup()` calls in the given query.
+  ///
+  /// `lookup()` names its dimension table with a string literal argument instead of the FROM clause, so the table is
+  /// absent from the query's data source and has to be collected explicitly for the table-level access checks to see
+  /// it. The names are returned exactly as written, which is how `LookupTransformFunction` resolves them on the
+  /// server, so the authorized table is always the one actually read.
+  @VisibleForTesting
+  static Set<String> extractLookupTableNames(PinotQuery pinotQuery) {
+    Set<String> lookupTableNames = new HashSet<>();
+    for (Expression expression : pinotQuery.getSelectList()) {
+      collectLookupTableNames(expression, lookupTableNames);
+    }
+    collectLookupTableNames(pinotQuery.getFilterExpression(), lookupTableNames);
+    collectLookupTableNames(pinotQuery.getHavingExpression(), lookupTableNames);
+    collectLookupTableNames(pinotQuery.getGroupByList(), lookupTableNames);
+    collectLookupTableNames(pinotQuery.getOrderByList(), lookupTableNames);
+    return lookupTableNames;
+  }
+
+  private static void collectLookupTableNames(@Nullable List<Expression> expressions, Set<String> lookupTableNames) {
+    if (expressions != null) {
+      for (Expression expression : expressions) {
+        collectLookupTableNames(expression, lookupTableNames);
+      }
+    }
+  }
+
+  private static void collectLookupTableNames(@Nullable Expression expression, Set<String> lookupTableNames) {
+    if (expression == null || expression.getType() != ExpressionType.FUNCTION) {
+      return;
+    }
+    Function functionCall = expression.getFunctionCall();
+    List<Expression> operands = functionCall.getOperands();
+    if (LOOKUP_FUNCTION.equals(functionCall.getOperator()) && !operands.isEmpty()) {
+      Literal tableName = operands.get(0).getLiteral();
+      // A non-literal table name is rejected by LookupTransformFunction on the server
+      if (tableName != null && tableName.isSetStringValue()) {
+        lookupTableNames.add(tableName.getStringValue());
+      }
+    }
+    // Recurse regardless, so that a lookup() nested inside another lookup()'s join value is collected too
+    collectLookupTableNames(operands, lookupTableNames);
+  }
+
   /// 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) {
@@ -2165,7 +2249,7 @@ public abstract class BaseSingleStageBrokerRequestHandler extends BaseBrokerRequ
         case "as":
           fixColumnName(rawTableName, functionCall.getOperands().get(0), columnNameMap, ignoreCase);
           break;
-        case "lookup":
+        case LOOKUP_FUNCTION:
           // LOOKUP function looks up another table's schema, skip the check for now.
           break;
         default:
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 1c0f303e66b..0d441a05038 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
@@ -1669,4 +1669,41 @@ public class BaseSingleStageBrokerRequestHandlerTest {
     Assert.assertEquals(serverTableName, baseOfflineTable,
         "EXPLAIN must route to the base table, not the MV; SPLIT must not have swapped routing");
   }
+
+  @Test
+  public void testExtractLookupTableNames() {
+    // No lookup at all
+    Assert.assertEquals(extractLookupTableNames("SELECT col FROM tbl WHERE col > 1"), Set.of());
+
+    // Select list, filter, group-by, order-by and having
+    Assert.assertEquals(extractLookupTableNames("SELECT lookup('dimA', 'c', 'pk', col) FROM tbl"), Set.of("dimA"));
+    Assert.assertEquals(extractLookupTableNames("SELECT col FROM tbl WHERE lookup('dimA', 'c', 'pk', col) = 'x'"),
+        Set.of("dimA"));
+    Assert.assertEquals(
+        extractLookupTableNames("SELECT COUNT(*) FROM tbl GROUP BY lookup('dimA', 'c', 'pk', col)"), Set.of("dimA"));
+    Assert.assertEquals(extractLookupTableNames("SELECT col FROM tbl ORDER BY lookup('dimA', 'c', 'pk', col)"),
+        Set.of("dimA"));
+    Assert.assertEquals(
+        extractLookupTableNames("SELECT COUNT(*) FROM tbl GROUP BY col HAVING COUNT(*) > 1 AND MAX(col) > 0"),
+        Set.of());
+
+    // Wrapped in another function, aliased, and nested inside another lookup's join value
+    Assert.assertEquals(extractLookupTableNames("SELECT UPPER(lookup('dimA', 'c', 'pk', col)) AS a FROM tbl"),
+        Set.of("dimA"));
+    Assert.assertEquals(
+        extractLookupTableNames("SELECT lookup('dimA', 'c', 'pk', lookup('dimB', 'c', 'pk', col)) FROM tbl"),
+        Set.of("dimA", "dimB"));
+
+    // The function name is canonicalized, so casing in the query must not hide the table
+    Assert.assertEquals(extractLookupTableNames("SELECT LOOKUP('dimA', 'c', 'pk', col) FROM tbl"), Set.of("dimA"));
+
+    // Multiple distinct dimension tables
+    Assert.assertEquals(extractLookupTableNames(
+            "SELECT lookup('dimA', 'c', 'pk', col) FROM tbl WHERE lookup('dimB', 'c', 'pk', col) = 'x'"),
+        Set.of("dimA", "dimB"));
+  }
+
+  private static Set<String> extractLookupTableNames(String sql) {
+    return BaseSingleStageBrokerRequestHandler.extractLookupTableNames(CalciteSqlParser.compileToPinotQuery(sql));
+  }
 }
diff --git a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/TableAccessControlIntegrationTest.java b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/TableAccessControlIntegrationTest.java
new file mode 100644
index 00000000000..e10c146acd2
--- /dev/null
+++ b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/TableAccessControlIntegrationTest.java
@@ -0,0 +1,263 @@
+/**
+ * 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.
+ */
+package org.apache.pinot.integration.tests;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.File;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiConsumer;
+import org.apache.avro.Schema.Field;
+import org.apache.avro.Schema.Type;
+import org.apache.avro.file.DataFileWriter;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericDatumWriter;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.common.exception.HttpErrorStatusException;
+import org.apache.pinot.spi.config.table.DimensionTableConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.apache.pinot.util.TestUtils;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.fail;
+
+
+/// Broker-side table-level access control over the single-stage engine: which tables a query is allowed to read, and
+/// how that interacts with row-level filters. Add cases here for any table a query reaches other than through its
+/// FROM clause.
+///
+/// The cluster has a regular table and a dimension table, and three principals with different access to them, so a
+/// case can pick the principal it needs instead of starting another cluster. Only the broker runs with access
+/// control; the controller and server are left open so table setup and segment upload need no credentials.
+public class TableAccessControlIntegrationTest extends BaseClusterIntegrationTest {
+  private static final String TABLE = "someTable";
+  private static final String DIM_TABLE = "someDimTable";
+
+  private static final String TABLE_KEY = "tableKey";
+  private static final String DIM_KEY = "dimKey";
+  private static final String DIM_VALUE = "dimValue";
+
+  private static final int NUM_ROWS = 10;
+
+  /// Authorized for every table
+  private static final Map<String, String> ADMIN_HEADER = Map.of("Authorization", "Basic YWRtaW46dmVyeXNlY3JldA==");
+  /// Authorized for [#TABLE] only
+  private static final Map<String, String> USER_HEADER = Map.of("Authorization", "Basic dXNlcjpzZWNyZXQ=");
+  /// Authorized for both tables, but with a row-level filter on [#DIM_TABLE]
+  private static final Map<String, String> RLS_USER_HEADER =
+      Map.of("Authorization", "Basic cmxzVXNlcjpybHNTZWNyZXQ=");
+
+  private static final String LOOKUP_QUERY =
+      "SELECT lookup('" + DIM_TABLE + "', '" + DIM_VALUE + "', '" + DIM_KEY + "', " + TABLE_KEY + ") FROM " + TABLE
+          + " ORDER BY " + TABLE_KEY;
+
+  private static String dimValueFor(long key) {
+    return "value-" + key;
+  }
+
+  @Override
+  public String getTableName() {
+    return TABLE;
+  }
+
+  @Override
+  protected void overrideBrokerConf(PinotConfiguration brokerConf) {
+    brokerConf.setProperty("pinot.broker.enable.row.column.level.auth", "true");
+    brokerConf.setProperty("pinot.broker.access.control.class",
+        "org.apache.pinot.broker.broker.BasicAuthAccessControlFactory");
+    brokerConf.setProperty("pinot.broker.access.control.principals", "admin,user,rlsUser");
+    brokerConf.setProperty("pinot.broker.access.control.principals.admin.password", "verysecret");
+    brokerConf.setProperty("pinot.broker.access.control.principals.user.password", "secret");
+    brokerConf.setProperty("pinot.broker.access.control.principals.user.tables", TABLE);
+    brokerConf.setProperty("pinot.broker.access.control.principals.rlsUser.password", "rlsSecret");
+    brokerConf.setProperty("pinot.broker.access.control.principals.rlsUser.tables", TABLE + "," + DIM_TABLE);
+    brokerConf.setProperty("pinot.broker.access.control.principals.rlsUser." + DIM_TABLE + ".rls",
+        DIM_KEY + " = 1");
+  }
+
+  @Override
+  public Schema createSchema() {
+    return new Schema.SchemaBuilder()
+        .setSchemaName(TABLE)
+        .addSingleValueDimension(TABLE_KEY, FieldSpec.DataType.LONG)
+        .build();
+  }
+
+  @Override
+  public TableConfig createOfflineTableConfig() {
+    return new TableConfigBuilder(TableType.OFFLINE).setTableName(TABLE).build();
+  }
+
+  private Schema createDimSchema() {
+    return new Schema.SchemaBuilder()
+        .setSchemaName(DIM_TABLE)
+        .addSingleValueDimension(DIM_KEY, FieldSpec.DataType.LONG)
+        .addSingleValueDimension(DIM_VALUE, FieldSpec.DataType.STRING)
+        .setPrimaryKeyColumns(List.of(DIM_KEY))
+        .build();
+  }
+
+  private TableConfig createDimTableConfig() {
+    return new TableConfigBuilder(TableType.OFFLINE)
+        .setTableName(DIM_TABLE)
+        .setDimensionTableConfig(new DimensionTableConfig(false, false))
+        .setIsDimTable(true)
+        .build();
+  }
+
+  private static Field avroField(String name, Type type) {
+    return new Field(name, org.apache.avro.Schema.create(type), null, null);
+  }
+
+  /// Writes `NUM_ROWS` records, applying `fillRecord` to each one to set the column values for row `i`.
+  private File createAvroFile(String name, List<Field> fields, BiConsumer<GenericData.Record, Integer> fillRecord)
+      throws Exception {
+    org.apache.avro.Schema avroSchema = org.apache.avro.Schema.createRecord(name, null, null, false);
+    avroSchema.setFields(fields);
+    File file = new File(_tempDir, name + ".avro");
+    try (DataFileWriter<GenericData.Record> writer = new DataFileWriter<>(new GenericDatumWriter<>(avroSchema))) {
+      writer.create(avroSchema, file);
+      for (int i = 0; i < NUM_ROWS; i++) {
+        GenericData.Record record = new GenericData.Record(avroSchema);
+        fillRecord.accept(record, i);
+        writer.append(record);
+      }
+    }
+    return file;
+  }
+
+  private void addTable(String tableName, Schema schema, TableConfig tableConfig, File avroFile)
+      throws Exception {
+    addSchema(schema);
+    addTableConfig(tableConfig);
+    File segmentDir = new File(_segmentDir, tableName);
+    File tarDir = new File(_tarDir, tableName);
+    TestUtils.ensureDirectoriesExistAndEmpty(segmentDir, tarDir);
+    ClusterIntegrationTestUtils.buildSegmentsFromAvro(List.of(avroFile), tableConfig, schema, 0, segmentDir, tarDir);
+    uploadSegments(tableName, tarDir);
+  }
+
+  @BeforeClass
+  public void setUp()
+      throws Exception {
+    TestUtils.ensureDirectoriesExistAndEmpty(_tempDir, _segmentDir, _tarDir);
+
+    startZk();
+    startController();
+    startBroker();
+    startServer();
+
+    File tableAvro = createAvroFile("table", List.of(avroField(TABLE_KEY, Type.LONG)),
+        (record, i) -> record.put(TABLE_KEY, (long) i));
+    addTable(TABLE, createSchema(), createOfflineTableConfig(), tableAvro);
+
+    File dimAvro = createAvroFile("dim", List.of(avroField(DIM_KEY, Type.LONG), avroField(DIM_VALUE, Type.STRING)),
+        (record, i) -> {
+          record.put(DIM_KEY, (long) i);
+          record.put(DIM_VALUE, dimValueFor(i));
+        });
+    addTable(DIM_TABLE, createDimSchema(), createDimTableConfig(), dimAvro);
+
+    TestUtils.waitForCondition(aVoid -> {
+      try {
+        JsonNode table = postQuery("SELECT COUNT(*) FROM " + TABLE, ADMIN_HEADER);
+        JsonNode dim = postQuery("SELECT COUNT(*) FROM " + DIM_TABLE, ADMIN_HEADER);
+        return table.get("resultTable").get("rows").get(0).get(0).asLong() == NUM_ROWS
+            && dim.get("resultTable").get("rows").get(0).get(0).asLong() == NUM_ROWS;
+      } catch (Exception e) {
+        return false;
+      }
+    }, 100L, 60_000L, "Failed to load data into both tables");
+  }
+
+  @AfterClass(alwaysRun = true)
+  public void tearDown()
+      throws Exception {
+    stopServer();
+    stopBroker();
+    stopController();
+    stopZk();
+    FileUtils.deleteDirectory(_tempDir);
+  }
+
+  private void assertForbidden(String query, Map<String, String> headers) {
+    try {
+      postQuery(query, headers);
+      fail("Expected 403 for query: " + query);
+    } catch (Exception e) {
+      Throwable cause = e.getCause() instanceof HttpErrorStatusException ? e.getCause() : e;
+      assertTrue(cause instanceof HttpErrorStatusException, "expected HttpErrorStatusException, got: " + cause);
+      assertEquals(((HttpErrorStatusException) cause).getStatusCode(), 403);
+    }
+  }
+
+  /// The dimension table named inside `lookup()` is subject to access control even though it is not the FROM table.
+  @Test
+  public void testLookupDeniedForUnauthorizedDimensionTable() {
+    assertForbidden(LOOKUP_QUERY, USER_HEADER);
+  }
+
+  /// A principal authorized for both tables still gets the looked-up values.
+  @Test
+  public void testLookupAllowedForAuthorizedDimensionTable()
+      throws Exception {
+    JsonNode response = postQuery(LOOKUP_QUERY, ADMIN_HEADER);
+    assertNoError(response);
+    JsonNode rows = response.get("resultTable").get("rows");
+    assertEquals(rows.size(), NUM_ROWS);
+    for (int i = 0; i < NUM_ROWS; i++) {
+      assertEquals(rows.get(i).get(0).asText(), dimValueFor(i));
+    }
+  }
+
+  /// `lookup()` resolves a row by primary key and never evaluates a filter against the dimension table, so a
+  /// row-level filter on that table cannot be applied and the query has to be rejected rather than returning
+  /// unfiltered rows.
+  @Test
+  public void testLookupRejectedWhenDimensionTableHasRowLevelFilter()
+      throws Exception {
+    // Querying the dimension table directly works and returns only the row the filter allows. That pins the
+    // rejection below to the filter rather than to a missing grant on the table.
+    JsonNode response = postQuery("SELECT " + DIM_VALUE + " FROM " + DIM_TABLE, RLS_USER_HEADER);
+    assertNoError(response);
+    JsonNode rows = response.get("resultTable").get("rows");
+    assertEquals(rows.size(), 1);
+    assertEquals(rows.get(0).get(0).asText(), dimValueFor(1));
+
+    assertForbidden(LOOKUP_QUERY, RLS_USER_HEADER);
+  }
+
+  /// A query that reads only its FROM table is unaffected.
+  @Test
+  public void testQueryWithoutLookupUnaffected()
+      throws Exception {
+    JsonNode response = postQuery("SELECT COUNT(*) FROM " + TABLE, USER_HEADER);
+    assertNoError(response);
+    assertEquals(response.get("resultTable").get("rows").get(0).get(0).asLong(), NUM_ROWS);
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org