[
https://issues.apache.org/jira/browse/DRILL-8548?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18107193#comment-18107193
]
ASF GitHub Bot commented on DRILL-8548:
---------------------------------------
cgivre commented on code in PR #3056:
URL: https://github.com/apache/drill/pull/3056#discussion_r3839544015
##########
exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/SqlConverter.java:
##########
@@ -245,6 +245,12 @@ public RelRoot toRel(final SqlNode validatedNode) {
RelNode project = LogicalProject.create(rel.rel,
Collections.emptyList(), expressions, rel.validatedRowType);
rel = RelRoot.of(project, rel.validatedRowType, rel.kind);
}
+
+ // Column-level SELECT authorization check. Done after SqlToRelConverter
has
+ // resolved all column references (so we can trace each to its TableScan)
+ // and before flattenTypes/optimization (so column references are intact).
+ new ColumnAccessChecker(session, drillConfig,
cluster.getMetadataQuery()).check(rel.rel);
Review Comment:
**Planning-time cost is paid on every query, even when Ranger is disabled.**
`check()` walks the whole tree and calls
`RelMetadataQuery.getColumnOrigins()` once per output column of every node
*before* anything consults `authorizer.isEnabled()` — that test lives down in
`ColumnAccessChecker.enforceColumnAccess()`, which only runs after the
traversal has already happened. `getColumnOrigins` is not a cheap metadata
query on wide row types or deep plans.
Since `drill.exec.security.ranger.enabled` defaults to `false`, every
existing Drill deployment pays this on every `toRel` for a feature it isn't
using.
Suggest hoisting the check here:
```java
AccessAuthorizer authorizer =
AccessAuthorizerFactory.getAuthorizer(drillConfig);
if (authorizer.isEnabled()) {
new ColumnAccessChecker(session, drillConfig, cluster.getMetadataQuery(),
authorizer).check(rel.rel);
}
```
and passing the resolved `authorizer` into the checker so
`enforceColumnAccess` stops re-resolving it per table.
##########
exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/DrillCalciteCatalogReader.java:
##########
@@ -103,14 +106,99 @@ void disallowTemporaryTables() {
public Prepare.PreparingTable getTable(List<String> names) {
checkTemporaryTable(names);
Prepare.PreparingTable table = super.getTable(names);
- DrillTable drillTable;
- if (table != null && (drillTable = table.unwrap(DrillTable.class)) !=
null) {
- drillTable.setOptions(session.getOptions());
-
drillTable.setTableMetadataProviderManager(tableCache.getUnchecked(DrillTableKey.of(names,
drillTable)));
+ if (table != null) {
+ // Ranger SELECT authorization check for ALL table types, including
+ // JDBC storage plugin tables (JdbcTable) that are not DrillTable.
+ checkTableAccess(table, names);
+
+ DrillTable drillTable = table.unwrap(DrillTable.class);
+ if (drillTable != null) {
+ drillTable.setOptions(session.getOptions());
+
drillTable.setTableMetadataProviderManager(tableCache.getUnchecked(DrillTableKey.of(names,
drillTable)));
+ }
}
return table;
}
+ /**
+ * Checks SELECT permission on the resolved table via the configured {@link
AccessAuthorizer}
+ * (Ranger by default). No-op when authorization is disabled (fail-open).
System schemas
+ * (INFORMATION_SCHEMA, sys) are bypassed inside the authorizer
implementation.
+ *
+ * <p>Extracts datasource/schema/table from the resolved qualified name so
it works
+ * for both DrillTable (native storage plugins) and non-DrillTable (JDBC
storage plugin).
+ * The {@code names} argument is used as a fallback to determine the
datasource when
+ * the qualified name does not expose it.
+ */
+ private void checkTableAccess(Prepare.PreparingTable table, List<String>
names) {
+ AccessAuthorizer authorizer =
AccessAuthorizerFactory.getAuthorizer(drillConfig);
+ if (!authorizer.isEnabled()) {
+ return; // fail-open when disabled
+ }
+ // Use the resolved qualified name (includes default schema resolution)
rather than
+ // the raw input names, which may be incomplete when the user omits the
schema.
+ //
+ // Ranger four-level resource model: datasource / schema / table / column.
+ // The first segment of qualifiedName is the datasource (storage plugin
name);
+ // the LAST segment is the table; any segments in between form the schema
path.
+ // For example "mysql.shf.users" -> datasource=mysql, schema=shf,
table=users.
+ // The schema MUST NOT include the datasource prefix, otherwise Ranger
policy
+ // matching fails (policy has schema=shf but request sends
schema=mysql.shf).
+ //
+ // Some backends have no schema concept (e.g. a flat file store). To keep
the
+ // four-level model uniform, we synthesize a default schema per datasource
via
+ // getDefaultSchemaByDataSource(). The default branch returns the
datasource
+ // name itself so each storage plugin gets its own default schema namespace
+ // until an explicit mapping is added.
+ List<String> qualifiedName = table.getQualifiedName();
+ String tableName = qualifiedName.get(qualifiedName.size() - 1);
+ String dataSource;
+ String schemaPath;
+ if (qualifiedName.size() > 2) {
+ // datasource.schema.table OR datasource.subschema.table
+ dataSource = qualifiedName.get(0);
+ schemaPath = SchemaUtilities.getSchemaPath(qualifiedName.subList(1,
qualifiedName.size() - 1));
+ } else if (qualifiedName.size() == 2) {
+ // datasource.table — backend has no schema; synthesize a default so the
+ // four-level resource stays complete (Ranger policy matching requires a
+ // non-null schema key when schema is a mandatory resource).
+ dataSource = qualifiedName.get(0);
+ schemaPath = getDefaultSchemaByDataSource(dataSource);
+ } else {
+ // Single-element qualified name: fall back to the input names list to
find the datasource.
+ dataSource = !names.isEmpty() ? names.get(0) : tableName;
+ schemaPath = getDefaultSchemaByDataSource(dataSource);
+ }
+ String userName = session.getCredentials().getUserName();
+
+ if (!authorizer.checkTableAccess(userName, dataSource, schemaPath,
tableName, AccessTypes.SELECT)) {
+ throw UserException.permissionError()
+ .message("Access denied: user '%s' lacks SELECT privilege on
%s.%s.%s",
+ userName, dataSource, schemaPath, tableName)
+ .build(logger);
+ }
+ }
+
+ /**
+ * Returns the default schema name to use when a table's qualified name does
not
+ * contain an explicit schema segment (i.e. two-segment {@code
datasource.table}
+ * or a single-segment fallback). This keeps the Ranger four-level resource
+ * model complete even for backends that have no native schema concept.
+ *
+ * <p>Add explicit cases below as new storage plugins are integrated. The
+ * {@code default} branch returns the datasource name itself so each plugin
+ * gets a distinct default schema namespace without further
configuration.</p>
+ *
+ * @param dataSource the storage plugin / datasource name
+ * @return a non-null default schema name
+ */
+ static String getDefaultSchemaByDataSource(String dataSource) {
+ return switch (dataSource.toLowerCase()) {
+ case "dfs", "cp" -> "default";
+ default -> dataSource;
+ };
Review Comment:
Hardcoding `dfs` and `cp` here doesn't hold: storage plugin names are chosen
by the operator. `dfs` and `cp` are conventions from the bundled
`bootstrap-storage-plugins.json`, not reserved names — a site can rename them,
delete them, or add a second filesystem plugin under any name, and then the
synthesized schema silently changes from `"default"` to the plugin name and
stops matching the Ranger policy the admin wrote.
Since this value participates in policy matching, it should be configurable
rather than compiled in — e.g. a `drill.exec.security.ranger.default_schema`
map in `drill-module.conf`, defaulting to the plugin name for anything not
listed. That also keeps `DrillCalciteCatalogReader` from having to know
anything about specific plugins.
Minor: the javadoc says "Add explicit cases below as new storage plugins are
integrated" — that's the part I'd like to avoid, since it makes the security
mapping a code change rather than a config change.
##########
exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/AccessAuthorizerFactory.java:
##########
@@ -0,0 +1,114 @@
+/*
+ * 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.drill.exec.security.ranger;
+
+import org.apache.drill.common.config.DrillConfig;
+import org.apache.drill.exec.ExecConstants;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Singleton factory for {@link AccessAuthorizer}. Follows the same
configuration-driven
+ * reflective loading pattern as
+ * {@link org.apache.drill.exec.rpc.user.security.UserAuthenticatorFactory}.
+ *
+ * <p>When {@code drill.exec.security.ranger.enabled} is {@code false} (the
default),
+ * a {@link NoOpAccessAuthorizer} is returned. When enabled, the
implementation class
+ * named by {@code drill.exec.security.ranger.impl} is loaded reflectively and
+ * initialized with the configured service name.</p>
+ */
+public class AccessAuthorizerFactory {
+ private static final Logger logger =
LoggerFactory.getLogger(AccessAuthorizerFactory.class);
+
+ // Default values used when the corresponding config keys are absent.
+ // Package-private so tests can verify the default-impl selection logic
+ // without triggering RangerAccessAuthorizer.init() (which calls
+ // RangerPluginClassLoader.getInstance() and crashes the JVM in the test
+ // environment).
+ static final String DEFAULT_AUTHORIZER_IMPL =
+ "org.apache.drill.exec.security.ranger.RangerAccessAuthorizer";
+ static final String DEFAULT_SERVICE_NAME = "drill";
+
+ private static volatile AccessAuthorizer instance;
+
+ private AccessAuthorizerFactory() {
+ }
+
+ /**
+ * Returns the singleton {@link AccessAuthorizer} instance, initializing it
from
+ * the given configuration on first call.
+ *
+ * @param config the Drill configuration
+ * @return the authorizer (never {@code null})
+ */
+ public static AccessAuthorizer getAuthorizer(DrillConfig config) {
Review Comment:
**JVM-wide static singleton pinned by whichever `DrillConfig` calls first.**
`instance` is `static`, has no reset, and ignores the `config` argument on
every call after the first. Drill routinely runs more than one Drillbit in a
single JVM — `ClusterFixture` / `BaseTestQuery` spin up multi-node clusters,
and embedded mode shares the process with the client. The first
`getAuthorizer(config)` call wins for the lifetime of the process, so a second
Drillbit configured with Ranger enabled silently gets the
`NoOpAccessAuthorizer` created for the first one (or vice versa).
That also makes this effectively untestable in-process: once any test
touches it, every later test in the same fork sees the cached instance.
`AccessAuthorizerFactoryTest` works around this today, which is a signal.
Suggest scoping the authorizer to `DrillbitContext` instead — construct it
once in `Drillbit.run()` and hand it to `SqlConverter` /
`DrillCalciteCatalogReader` through the context, the same way Drill scopes
`StoragePluginRegistry` and `OptionManager`. That removes the static entirely
and makes lifecycle (including `cleanUp()` on the Ranger plugin, which
currently never gets called) explicit.
##########
exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/AccessAuthorizerFactory.java:
##########
@@ -0,0 +1,114 @@
+/*
+ * 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.drill.exec.security.ranger;
+
+import org.apache.drill.common.config.DrillConfig;
+import org.apache.drill.exec.ExecConstants;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Singleton factory for {@link AccessAuthorizer}. Follows the same
configuration-driven
+ * reflective loading pattern as
+ * {@link org.apache.drill.exec.rpc.user.security.UserAuthenticatorFactory}.
+ *
+ * <p>When {@code drill.exec.security.ranger.enabled} is {@code false} (the
default),
+ * a {@link NoOpAccessAuthorizer} is returned. When enabled, the
implementation class
+ * named by {@code drill.exec.security.ranger.impl} is loaded reflectively and
+ * initialized with the configured service name.</p>
+ */
+public class AccessAuthorizerFactory {
+ private static final Logger logger =
LoggerFactory.getLogger(AccessAuthorizerFactory.class);
+
+ // Default values used when the corresponding config keys are absent.
+ // Package-private so tests can verify the default-impl selection logic
+ // without triggering RangerAccessAuthorizer.init() (which calls
+ // RangerPluginClassLoader.getInstance() and crashes the JVM in the test
+ // environment).
+ static final String DEFAULT_AUTHORIZER_IMPL =
+ "org.apache.drill.exec.security.ranger.RangerAccessAuthorizer";
+ static final String DEFAULT_SERVICE_NAME = "drill";
+
+ private static volatile AccessAuthorizer instance;
+
+ private AccessAuthorizerFactory() {
+ }
+
+ /**
+ * Returns the singleton {@link AccessAuthorizer} instance, initializing it
from
+ * the given configuration on first call.
+ *
+ * @param config the Drill configuration
+ * @return the authorizer (never {@code null})
+ */
+ public static AccessAuthorizer getAuthorizer(DrillConfig config) {
+ if (instance != null) {
+ return instance;
+ }
+ synchronized (AccessAuthorizerFactory.class) {
+ if (instance != null) {
+ return instance;
+ }
+ instance = createAuthorizer(config);
+ return instance;
+ }
+ }
+
+ /**
+ * Resolves the authorizer implementation class name from configuration.
+ * Package-private for unit testing the selection logic without triggering
+ * {@code RangerAccessAuthorizer.init()}.
+ */
+ static String getImplClassName(DrillConfig config) {
+ return config.hasPath(ExecConstants.RANGER_AUTHORIZER_IMPL)
+ ? config.getString(ExecConstants.RANGER_AUTHORIZER_IMPL)
+ : DEFAULT_AUTHORIZER_IMPL;
+ }
+
+ /**
+ * Resolves the Ranger service name from configuration.
+ * Package-private for unit testing the selection logic.
+ */
+ static String getServiceName(DrillConfig config) {
+ return config.hasPath(ExecConstants.RANGER_SERVICE_NAME)
+ ? config.getString(ExecConstants.RANGER_SERVICE_NAME)
+ : DEFAULT_SERVICE_NAME;
+ }
+
+ private static AccessAuthorizer createAuthorizer(DrillConfig config) {
+ boolean enabled = config.hasPath(ExecConstants.RANGER_AUTH_ENABLED)
+ && config.getBoolean(ExecConstants.RANGER_AUTH_ENABLED);
+ if (!enabled) {
+ logger.info("Ranger authorization is disabled
(drill.exec.security.ranger.enabled=false)");
+ return new NoOpAccessAuthorizer();
+ }
+ String impl = getImplClassName(config);
+ String serviceName = getServiceName(config);
+ logger.info("Initializing Ranger authorizer: impl={}, service={}", impl,
serviceName);
+ try {
+ Class<?> clazz = Class.forName(impl);
+ AccessAuthorizer authorizer = (AccessAuthorizer)
clazz.getDeclaredConstructor().newInstance();
+ authorizer.init(serviceName);
+ logger.info("Ranger authorizer initialized, enabled={}",
authorizer.isEnabled());
+ return authorizer;
+ } catch (Exception e) {
+ logger.error("Failed to initialize Ranger authorizer {}, falling back to
fail-closed", impl, e);
+ throw new RuntimeException("Failed to initialize Ranger authorizer
impl:" + impl
+ + ", serviceName:" + serviceName, e);
+ }
Review Comment:
The log message says "falling back to fail-closed", but nothing falls back —
the next statement rethrows. Propagating is the right behaviour (a Drillbit
that can't initialise its authorizer should refuse to start, and
`Drillbit.run()` will surface it), the message just contradicts the code and
will send an operator looking for a fallback authorizer that doesn't exist.
```java
logger.error("Failed to initialize Ranger authorizer {}; Drillbit startup
will abort", impl, e);
throw new DrillRuntimeException(...);
```
Also worth using `DrillRuntimeException` rather than raw `RuntimeException`
for consistency with the rest of the codebase, and dropping `e.getMessage()`
string-concatenation in favour of the cause (which is already passed).
##########
exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/ColumnAccessChecker.java:
##########
@@ -0,0 +1,350 @@
+/*
+ * 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.drill.exec.planner.sql.conversion;
+
+import org.apache.calcite.plan.RelOptTable;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.RelShuttleImpl;
+import org.apache.calcite.rel.core.TableScan;
+import org.apache.calcite.rel.logical.LogicalAggregate;
+import org.apache.calcite.rel.logical.LogicalFilter;
+import org.apache.calcite.rel.logical.LogicalJoin;
+import org.apache.calcite.rel.logical.LogicalProject;
+import org.apache.calcite.rel.logical.LogicalSort;
+import org.apache.calcite.rel.metadata.RelColumnOrigin;
+import org.apache.calcite.rel.metadata.RelMetadataQuery;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexSubQuery;
+import org.apache.calcite.rex.RexVisitorImpl;
+import org.apache.drill.common.config.DrillConfig;
+import org.apache.drill.common.exceptions.UserException;
+import org.apache.drill.exec.planner.sql.SchemaUtilities;
+import org.apache.drill.exec.rpc.user.UserSession;
+import org.apache.drill.exec.security.ranger.AccessAuthorizer;
+import org.apache.drill.exec.security.ranger.AccessAuthorizerFactory;
+import org.apache.drill.exec.security.ranger.AccessTypes;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Visitor that traverses a RelNode tree and enforces column-level SELECT
authorization
+ * via the configured {@link AccessAuthorizer} (Ranger by default).
+ *
+ * <p><b>Design:</b> For every RelNode that carries {@link RexNode} expressions
+ * (Project, Filter, Join, Aggregate, Sort), the visitor collects all {@link
RexInputRef}s
+ * and uses Calcite's {@link RelMetadataQuery#getColumnOrigins(RelNode, int)}
to trace each
+ * referenced column back to its originating {@link TableScan} column index.
This correctly
+ * handles multi-hop projections, filters, joins, and aggregations.</p>
+ *
+ * <p>For a {@link TableScan} that has NO traced column references (e.g.
+ * {@code SELECT * FROM t} with no intervening Project), ALL columns of that
table
+ * are checked.</p>
+ *
+ * <p>System schemas (INFORMATION_SCHEMA, sys) are bypassed inside the
authorizer
+ * implementation. When authorization is disabled, the visitor is a no-op
(fail-open).</p>
+ */
+class ColumnAccessChecker extends RelShuttleImpl {
+
+ private static final Logger logger =
LoggerFactory.getLogger(ColumnAccessChecker.class);
+
+ private final UserSession session;
+ private final DrillConfig drillConfig;
+ private final RelMetadataQuery mq;
+
+ // Records each table's referenced column indices. Uses IdentityHashMap
because
+ // RelOptTable equals/hashCode may be expensive or not identity-based.
+ private final Map<RelOptTable, Set<Integer>> tableToReferencedCols = new
IdentityHashMap<>();
+
+ ColumnAccessChecker(UserSession session, DrillConfig drillConfig,
RelMetadataQuery mq) {
+ this.session = session;
+ this.drillConfig = drillConfig;
+ this.mq = mq;
+ }
+
+ /**
+ * Entry point: traverse the tree and enforce column-level access.
+ */
+ void check(RelNode root) {
+ // Also trace the root node's output columns (covers bare TableScan root or
+ // top-level Project output).
+ traceOutputColumns(root);
+
+ // Walk the tree to collect RexInputRef origins from all
expression-bearing nodes.
+ root.accept(this);
+ }
+
+ // ------------------------------------------------------------------
+ // RelShuttle overrides — collect RexInputRefs from expression-bearing nodes
+ // ------------------------------------------------------------------
+
+ @Override
+ public RelNode visit(LogicalProject project) {
+ collectRefs(project.getProjects(), project.getInput());
+ return super.visit(project);
+ }
+
+ @Override
+ public RelNode visit(LogicalFilter filter) {
+ if (filter.getCondition() != null) {
+ analyzeRex(filter.getCondition(), filter.getInput(), -1, null);
+ }
+ return super.visit(filter);
+ }
+
+ @Override
+ public RelNode visit(LogicalJoin join) {
+ if (join.getCondition() != null) {
+ int leftCount = join.getLeft().getRowType().getFieldCount();
+ analyzeRex(join.getCondition(), join.getRight(), leftCount,
join.getLeft());
+ }
+ return super.visit(join);
+ }
+
+ @Override
+ public RelNode visit(LogicalAggregate aggregate) {
+ for (int i : aggregate.getGroupSet()) {
+ traceColumnOrigin(aggregate.getInput(), i);
+ }
+ return super.visit(aggregate);
+ }
+
+ @Override
+ public RelNode visit(LogicalSort sort) {
+ if (sort.getCollation() != null) {
+ sort.getCollation().getFieldCollations().forEach(fc ->
+ traceColumnOrigin(sort.getInput(), fc.getFieldIndex()));
+ }
+ return super.visit(sort);
+ }
+
+ @Override
+ public RelNode visit(TableScan scan) {
+ RelOptTable table = scan.getTable();
+
+ // Determine which columns to check: traced columns, or ALL if none were
traced
+ // (SELECT * FROM t case).
+ Set<Integer> referencedColIndices = tableToReferencedCols.get(table);
+ List<String> allColumnNames = scan.getRowType().getFieldNames();
+
+ Set<String> columnsToCheck;
+ if (referencedColIndices == null || referencedColIndices.isEmpty()) {
+ // SELECT * — check all columns
+ columnsToCheck = new HashSet<>(allColumnNames);
+ } else {
+ columnsToCheck = new HashSet<>();
+ for (int idx : referencedColIndices) {
+ if (idx >= 0 && idx < allColumnNames.size()) {
+ columnsToCheck.add(allColumnNames.get(idx));
+ }
+ }
+ }
+
+ if (columnsToCheck.isEmpty()) {
+ return scan;
+ }
+
+ enforceColumnAccess(table, columnsToCheck);
+ return scan;
+ }
+
+ /**
+ * Traces the output columns of a RelNode back to their table-scan origins.
+ */
+ private void traceOutputColumns(RelNode node) {
+ if (node == null) {
+ return;
+ }
+ int fieldCount = node.getRowType().getFieldCount();
+ for (int i = 0; i < fieldCount; i++) {
+ traceColumnOrigin(node, i);
+ }
+ }
+
+ /**
+ * Traces a single output column of {@code node} at index {@code
columnIndex} back to
+ * table-scan origins, recording them in {@link #tableToReferencedCols}.
+ */
+ private void traceColumnOrigin(RelNode node, int columnIndex) {
+ if (node == null || mq == null) {
+ return;
+ }
+ Set<RelColumnOrigin> origins;
+ try {
+ origins = mq.getColumnOrigins(node, columnIndex);
+ } catch (Exception e) {
+ logger.debug("getColumnOrigins failed for {} column {}", node,
columnIndex, e);
+ return;
+ }
+ if (origins == null) {
+ return;
+ }
+ for (RelColumnOrigin origin : origins) {
+ RelOptTable originTable = origin.getOriginTable();
+ if (originTable != null) {
+ // Record origins for ALL table types (DrillTable, JdbcTable, etc.).
+ // Previously this only recorded DrillTable origins, which caused
+ // JDBC storage plugin tables (JdbcTable) to be skipped entirely.
+ tableToReferencedCols
+ .computeIfAbsent(originTable, k -> new HashSet<>())
+ .add(origin.getOriginColumnOrdinal());
+ }
+ }
+ }
+
+ /**
+ * Collects RexInputRefs from a list of RexNodes and traces each to its
+ * table-scan origin via the input node's metadata. Also processes any
+ * {@link RexSubQuery} found in the expressions (scalar/IN/EXISTS subqueries)
+ * so that column references inside subqueries are authorized.
+ */
+ private void collectRefs(List<RexNode> rexNodes, RelNode inputNode) {
+ if (rexNodes == null || inputNode == null) {
+ return;
+ }
+ for (RexNode rex : rexNodes) {
+ analyzeRex(rex, inputNode, -1, null);
+ }
+ }
+
+ /**
+ * Analyzes a {@link RexNode} expression, collecting {@link RexInputRef}s and
+ * {@link RexSubQuery}s, tracing each input ref to its table-scan origin and
+ * recursively visiting each subquery's {@link RelNode} tree.
+ *
+ * @param rex the expression to analyze
+ * @param inputNode the input RelNode that RexInputRefs resolve against
+ * @param leftCount if {@code >= 0}, indicates a join condition: refs with
+ * index {@code < leftCount} resolve against {@code
leftInput},
+ * others resolve against {@code inputNode} (the right
input)
+ * with offset {@code leftCount}. If {@code < 0}, all refs
+ * resolve against {@code inputNode}.
+ * @param leftInput the left input of a join, or {@code null} when
+ * {@code leftCount < 0}.
+ */
+ private void analyzeRex(RexNode rex, RelNode inputNode, int leftCount,
RelNode leftInput) {
+ if (rex == null) {
+ return;
+ }
+ Set<Integer> refs = new HashSet<>();
+ List<RexSubQuery> subQueries = new ArrayList<>();
+ rex.accept(new RexRefCollector(refs, subQueries));
+ for (int refIndex : refs) {
+ if (leftCount >= 0 && refIndex < leftCount) {
+ traceColumnOrigin(leftInput, refIndex);
+ } else if (leftCount >= 0) {
+ traceColumnOrigin(inputNode, refIndex - leftCount);
+ } else {
+ traceColumnOrigin(inputNode, refIndex);
+ }
+ }
+ for (RexSubQuery sq : subQueries) {
+ // Trace the subquery's output columns to their table-scan origins.
+ // For scalar subqueries (e.g. SELECT sum(user_id) FROM t), this traces
+ // the aggregate output back to the underlying table column.
+ traceOutputColumns(sq.rel);
+ // Recursively visit the subquery's RelNode tree so that RexInputRefs
+ // inside the subquery (e.g. columns in WHERE/SELECT of the subquery)
+ // are also collected and traced.
+ sq.rel.accept(this);
+ }
+ }
+
+ /**
+ * Enforces column-level access for the given table and column set.
+ */
+ private void enforceColumnAccess(RelOptTable table, Set<String> columns) {
+ AccessAuthorizer authorizer =
AccessAuthorizerFactory.getAuthorizer(drillConfig);
+ if (!authorizer.isEnabled()) {
+ return; // fail-open when disabled
+ }
+
+ // Resolve datasource / schema / table from the qualified name, consistent
+ // with DrillCalciteCatalogReader.checkTableAccess(). This works for ALL
+ // table types (DrillTable, JdbcTable, etc.) — previously this method
+ // required a DrillTable and skipped JdbcTable, leaving JDBC storage
+ // plugin tables without column-level authorization.
+ List<String> qualifiedName = table.getQualifiedName();
+ String tableName = qualifiedName.get(qualifiedName.size() - 1);
+ String dataSource;
+ String schemaPath;
+ if (qualifiedName.size() > 2) {
+ dataSource = qualifiedName.get(0);
+ schemaPath = SchemaUtilities.getSchemaPath(qualifiedName.subList(1,
qualifiedName.size() - 1));
+ } else if (qualifiedName.size() == 2) {
+ dataSource = qualifiedName.get(0);
+ schemaPath =
DrillCalciteCatalogReader.getDefaultSchemaByDataSource(dataSource);
+ } else {
+ dataSource = tableName;
+ schemaPath =
DrillCalciteCatalogReader.getDefaultSchemaByDataSource(dataSource);
+ }
+ String userName = session.getCredentials().getUserName();
+
+ if (!authorizer.checkColumnAccess(userName, dataSource, schemaPath,
tableName, columns, AccessTypes.SELECT)) {
+ throw UserException.permissionError()
+ .message("Access denied: user '%s' lacks SELECT privilege on one or
more columns " +
+ "(%s) of table %s.%s.%s", userName, columns, dataSource,
schemaPath, tableName)
+ .build(logger);
+ }
+ }
+
+ /**
+ * RexVisitor that collects all {@link RexInputRef} indices and
+ * {@link RexSubQuery} instances encountered in a {@link RexNode} tree.
+ * <p> dispatches to
+ * {@link #visitSubQuery(RexSubQuery)} (not {@code visitCall}), so a plain
+ * {@code RexInputRef}-only visitor silently skips over subqueries. This
+ * collector overrides {@code visitSubQuery} to capture the subquery and then
+ * continues traversing its operands so that nested {@link RexInputRef}s
+ * (e.g. the left side of {@code x IN (SELECT ...)}) and nested subqueries
+ * are also collected.</p>
+ */
+ private static final class RexRefCollector extends RexVisitorImpl<Void> {
+ private final Set<Integer> refs;
+ private final List<RexSubQuery> subQueries;
+
+ RexRefCollector(Set<Integer> refs, List<RexSubQuery> subQueries) {
+ super(true);
+ this.refs = refs;
+ this.subQueries = subQueries;
+ }
+
+ @Override
+ public Void visitInputRef(RexInputRef ref) {
Review Comment:
**Column-level bypass: correlated references are never authorized.**
`RexRefCollector` recognises `RexInputRef` and `RexSubQuery`. An outer-query
column referenced *from inside* a correlated subquery is neither — Calcite
represents it as a `RexFieldAccess` whose referenceExpr is a
`RexCorrelVariable`. So it is never traced and never checked:
```sql
> Integrate Apache Ranger authorization for Drill
> -----------------------------------------------
>
> Key: DRILL-8548
> URL: https://issues.apache.org/jira/browse/DRILL-8548
> Project: Apache Drill
> Issue Type: New Feature
> Components: Server
> Affects Versions: 1.23.0
> Reporter: shihuafeng
> Priority: Major
> Fix For: 1.23.0
>
>
> This issue introduces Apache Ranger as a pluggable authorization framework
> for Drill, enabling centralized table-level and column-level access control
> for Drill queries. It is a substantial feature spanning three layers: a new
> drill-ranger module , integration hooks in exec/java-exec, and distribution
> packaging. The design follows Drill's existing AccessAuthorizer SPI and
> Calcite's RelShuttle mechanism so that column-level checks happen in the
> toRel phase before physical planning
--
This message was sent by Atlassian Jira
(v8.20.10#820010)