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 <[email protected]>
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: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to