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