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]