[
https://issues.apache.org/jira/browse/DRILL-8548?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18123401#comment-18123401
]
ASF GitHub Bot commented on DRILL-8548:
---------------------------------------
cgivre commented on code in PR #3056:
URL: https://github.com/apache/drill/pull/3056#discussion_r4185239588
##########
exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/SqlConverter.java:
##########
@@ -245,6 +246,19 @@ 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).
+ //
+ // Skip the whole check when authorization is disabled: the tree walk plus
+ // a getColumnOrigins() metadata query per output column is measurable
+ // planning cost that deployments not using the feature must not pay on
+ // every query.
+ if (AccessAuthorizerManager.isEnabled(drillConfig)) {
+ new ColumnAccessChecker(session, drillConfig,
cluster.getMetadataQuery()).check(rel.rel);
Review Comment:
`DrillViewExpander` calls `toRel` on the view SQL, so this also runs during
view expansion and checks every column the view outputs. With `v = SELECT pub,
secret FROM t`, `SELECT pub FROM v` is denied, which breaks views as a
column-hiding mechanism.
Fix: skip the check when `toRel` is invoked for view expansion and run it
once on the top-level plan only.
##########
exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/ColumnAccessChecker.java:
##########
@@ -0,0 +1,511 @@
+/*
+ * 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.RelFieldCollation;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.RelShuttleImpl;
+import org.apache.calcite.rel.core.AggregateCall;
+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.RexCorrelVariable;
+import org.apache.calcite.rex.RexFieldAccess;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexSubQuery;
+import org.apache.calcite.rex.RexVisitor;
+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.rpc.user.UserSession;
+import org.apache.drill.exec.security.AccessAuthorizerManager;
+import org.apache.drill.exec.security.TableAccessResource;
+import org.apache.drill.exec.security.spi.AccessAuthorizer;
+import org.apache.drill.exec.security.spi.AccessType;
+import org.apache.drill.exec.security.spi.UserIdentity;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+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>Correlated subqueries are handled as well: a reference to an outer query
+ * column from inside a correlated subquery is represented by Calcite as a
+ * {@link RexFieldAccess} over a {@link RexCorrelVariable} (e.g. {@code
$cor0.id}),
+ * not as a {@link RexInputRef}. Each correlation variable carries a unique
+ * name {@code $corN} assigned by {@code SqlToRelConverter}; we keep an exact
+ * map from variable name to the enclosing {@link RelNode} row scope it refers
+ * to. The map entry is populated the FIRST time we see a given {@code $corN}
+ * during Rex traversal: we use {@code putIfAbsent(name, enclosingScope)} so
+ * that subsequent encounters (including cross-nested skip-level references
+ * from deeper subqueries to an outer {@code $cor0}) never overwrite the
+ * original, correct binding. This avoids the ambiguity of row-type-based
+ * heuristics: multiple enclosing scopes with identical schemas no longer
+ * cause false positives or missed detections, and no scope stack is needed —
+ * a single field tracks the innermost enclosing scope and is saved/restored
+ * around each subquery entry via try/finally.</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<>();
+
+ // The innermost enclosing row scope for correlated references. When we are
+ // about to descend into a RexSubQuery's Rel tree, we save the previous value
+ // and set this field to the outer-query inputNode that the subquery's
+ // $cor* variables are relative to; the saved value is restored in a finally
+ // block. Because variable → scope registration uses putIfAbsent in the Rex
+ // collector, a single field is sufficient even for deep nesting: skip-level
+ // references to an outer $corN reuse the binding that was established the
+ // first time that variable was seen (at a shallower enclosing scope).
+ private RelNode enclosingScope;
+
+ // Exact, name-keyed scope mapping: $cor0 → outer row scope, $cor1 → outer
+ // row scope, etc. Populated with putIfAbsent during Rex traversal so the
+ // first occurrence of each $corN establishes a permanent binding. Lookup is
+ // pure get(); a miss falls back (safely, in the over-approximate direction)
+ // to enclosingScope and emits a warning so unusual plans remain diagnosable.
+ private final Map<String, RelNode> correlationScopeByVar = new HashMap<>();
+
+ 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());
Review Comment:
Subqueries in the join condition get only the right input as their
correlation scope, but their correlated field indexes are into the combined
left+right row. For `a JOIN b ON a.x IN (SELECT y FROM c WHERE c.z =
a.secret)`, `a.secret` is resolved against `b`.
Fix: pass the join itself as the correlation scope so indexes resolve across
both inputs.
##########
exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/ColumnAccessChecker.java:
##########
@@ -0,0 +1,511 @@
+/*
+ * 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.RelFieldCollation;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.RelShuttleImpl;
+import org.apache.calcite.rel.core.AggregateCall;
+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.RexCorrelVariable;
+import org.apache.calcite.rex.RexFieldAccess;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexSubQuery;
+import org.apache.calcite.rex.RexVisitor;
+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.rpc.user.UserSession;
+import org.apache.drill.exec.security.AccessAuthorizerManager;
+import org.apache.drill.exec.security.TableAccessResource;
+import org.apache.drill.exec.security.spi.AccessAuthorizer;
+import org.apache.drill.exec.security.spi.AccessType;
+import org.apache.drill.exec.security.spi.UserIdentity;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+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>Correlated subqueries are handled as well: a reference to an outer query
+ * column from inside a correlated subquery is represented by Calcite as a
+ * {@link RexFieldAccess} over a {@link RexCorrelVariable} (e.g. {@code
$cor0.id}),
+ * not as a {@link RexInputRef}. Each correlation variable carries a unique
+ * name {@code $corN} assigned by {@code SqlToRelConverter}; we keep an exact
+ * map from variable name to the enclosing {@link RelNode} row scope it refers
+ * to. The map entry is populated the FIRST time we see a given {@code $corN}
+ * during Rex traversal: we use {@code putIfAbsent(name, enclosingScope)} so
+ * that subsequent encounters (including cross-nested skip-level references
+ * from deeper subqueries to an outer {@code $cor0}) never overwrite the
+ * original, correct binding. This avoids the ambiguity of row-type-based
+ * heuristics: multiple enclosing scopes with identical schemas no longer
+ * cause false positives or missed detections, and no scope stack is needed —
+ * a single field tracks the innermost enclosing scope and is saved/restored
+ * around each subquery entry via try/finally.</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<>();
+
+ // The innermost enclosing row scope for correlated references. When we are
+ // about to descend into a RexSubQuery's Rel tree, we save the previous value
+ // and set this field to the outer-query inputNode that the subquery's
+ // $cor* variables are relative to; the saved value is restored in a finally
+ // block. Because variable → scope registration uses putIfAbsent in the Rex
+ // collector, a single field is sufficient even for deep nesting: skip-level
+ // references to an outer $corN reuse the binding that was established the
+ // first time that variable was seen (at a shallower enclosing scope).
+ private RelNode enclosingScope;
+
+ // Exact, name-keyed scope mapping: $cor0 → outer row scope, $cor1 → outer
+ // row scope, etc. Populated with putIfAbsent during Rex traversal so the
+ // first occurrence of each $corN establishes a permanent binding. Lookup is
+ // pure get(); a miss falls back (safely, in the over-approximate direction)
+ // to enclosingScope and emits a warning so unusual plans remain diagnosable.
+ private final Map<String, RelNode> correlationScopeByVar = new HashMap<>();
+
+ 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);
+ }
+ // AggregateCall arguments (the x in SUM(x)), FILTER (WHERE ...) columns,
+ // and WITHIN GROUP collation columns are not covered by getGroupSet().
+ // Trace them explicitly rather than relying on an implicit Project beneath
+ // the aggregate (plan shape can change across Calcite upgrades).
+ for (AggregateCall call : aggregate.getAggCallList()) {
+ for (int arg : call.getArgList()) {
+ traceColumnOrigin(aggregate.getInput(), arg);
+ }
+ if (call.filterArg >= 0) {
+ traceColumnOrigin(aggregate.getInput(), call.filterArg);
+ }
+ if (call.getCollation() != null) {
+ for (RelFieldCollation fc : call.getCollation().getFieldCollations()) {
+ traceColumnOrigin(aggregate.getInput(), fc.getFieldIndex());
+ }
+ }
+ }
+ return super.visit(aggregate);
+ }
+
+ @Override
+ public RelNode visit(LogicalSort sort) {
+ if (sort.getCollation() != null) {
+ sort.getCollation().getFieldCollations().forEach(fc ->
+ traceColumnOrigin(sort.getInput(), fc.getFieldIndex()));
+ }
+ // LIMIT/OFFSET are RexNode expressions that may carry RexSubQuery or
+ // RexInputRef (e.g. LIMIT (SELECT MAX(amount) FROM ...)). analyzeRex
+ // descends into them; without it, subqueries reachable only from Sort
+ // would never be column-checked.
+ if (sort.fetch != null) {
+ analyzeRex(sort.fetch, sort.getInput(), -1, null);
+ }
+ if (sort.offset != null) {
+ analyzeRex(sort.offset, sort.getInput(), -1, null);
+ }
+ 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()) {
Review Comment:
Two issues with this branch:
1. Schemaless sources (JSON/CSV, Parquet w/o metadata): the row type is just
the dynamic star `**`, so that's the only name sent to Ranger. A policy
allowing `column=*` and denying `secret` matches `**`, and `SELECT *` returns
`secret`. Fix: if any checked column is a dynamic star, require access to all
columns (e.g. check `*` and fail if any column-level deny exists for the table).
2. "None referenced" is treated as `SELECT *`, so `COUNT(*)`, `SELECT 1`,
and `EXISTS (SELECT 1 ...)` are denied for anyone restricted on a single
column. Fix: distinguish "no columns read" from "star expanded"; for the
former, do a table-level check only.
##########
exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/ColumnAccessChecker.java:
##########
@@ -0,0 +1,511 @@
+/*
+ * 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.RelFieldCollation;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.RelShuttleImpl;
+import org.apache.calcite.rel.core.AggregateCall;
+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.RexCorrelVariable;
+import org.apache.calcite.rex.RexFieldAccess;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexSubQuery;
+import org.apache.calcite.rex.RexVisitor;
+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.rpc.user.UserSession;
+import org.apache.drill.exec.security.AccessAuthorizerManager;
+import org.apache.drill.exec.security.TableAccessResource;
+import org.apache.drill.exec.security.spi.AccessAuthorizer;
+import org.apache.drill.exec.security.spi.AccessType;
+import org.apache.drill.exec.security.spi.UserIdentity;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+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>Correlated subqueries are handled as well: a reference to an outer query
+ * column from inside a correlated subquery is represented by Calcite as a
+ * {@link RexFieldAccess} over a {@link RexCorrelVariable} (e.g. {@code
$cor0.id}),
+ * not as a {@link RexInputRef}. Each correlation variable carries a unique
+ * name {@code $corN} assigned by {@code SqlToRelConverter}; we keep an exact
+ * map from variable name to the enclosing {@link RelNode} row scope it refers
+ * to. The map entry is populated the FIRST time we see a given {@code $corN}
+ * during Rex traversal: we use {@code putIfAbsent(name, enclosingScope)} so
+ * that subsequent encounters (including cross-nested skip-level references
+ * from deeper subqueries to an outer {@code $cor0}) never overwrite the
+ * original, correct binding. This avoids the ambiguity of row-type-based
+ * heuristics: multiple enclosing scopes with identical schemas no longer
+ * cause false positives or missed detections, and no scope stack is needed —
+ * a single field tracks the innermost enclosing scope and is saved/restored
+ * around each subquery entry via try/finally.</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<>();
+
+ // The innermost enclosing row scope for correlated references. When we are
+ // about to descend into a RexSubQuery's Rel tree, we save the previous value
+ // and set this field to the outer-query inputNode that the subquery's
+ // $cor* variables are relative to; the saved value is restored in a finally
+ // block. Because variable → scope registration uses putIfAbsent in the Rex
+ // collector, a single field is sufficient even for deep nesting: skip-level
+ // references to an outer $corN reuse the binding that was established the
+ // first time that variable was seen (at a shallower enclosing scope).
+ private RelNode enclosingScope;
+
+ // Exact, name-keyed scope mapping: $cor0 → outer row scope, $cor1 → outer
+ // row scope, etc. Populated with putIfAbsent during Rex traversal so the
+ // first occurrence of each $corN establishes a permanent binding. Lookup is
+ // pure get(); a miss falls back (safely, in the over-approximate direction)
+ // to enclosingScope and emits a warning so unusual plans remain diagnosable.
+ private final Map<String, RelNode> correlationScopeByVar = new HashMap<>();
+
+ 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);
+ }
+ // AggregateCall arguments (the x in SUM(x)), FILTER (WHERE ...) columns,
+ // and WITHIN GROUP collation columns are not covered by getGroupSet().
+ // Trace them explicitly rather than relying on an implicit Project beneath
+ // the aggregate (plan shape can change across Calcite upgrades).
+ for (AggregateCall call : aggregate.getAggCallList()) {
+ for (int arg : call.getArgList()) {
+ traceColumnOrigin(aggregate.getInput(), arg);
+ }
+ if (call.filterArg >= 0) {
+ traceColumnOrigin(aggregate.getInput(), call.filterArg);
+ }
+ if (call.getCollation() != null) {
+ for (RelFieldCollation fc : call.getCollation().getFieldCollations()) {
+ traceColumnOrigin(aggregate.getInput(), fc.getFieldIndex());
+ }
+ }
+ }
+ return super.visit(aggregate);
+ }
+
+ @Override
+ public RelNode visit(LogicalSort sort) {
+ if (sort.getCollation() != null) {
+ sort.getCollation().getFieldCollations().forEach(fc ->
+ traceColumnOrigin(sort.getInput(), fc.getFieldIndex()));
+ }
+ // LIMIT/OFFSET are RexNode expressions that may carry RexSubQuery or
+ // RexInputRef (e.g. LIMIT (SELECT MAX(amount) FROM ...)). analyzeRex
+ // descends into them; without it, subqueries reachable only from Sort
+ // would never be column-checked.
+ if (sort.fetch != null) {
+ analyzeRex(sort.fetch, sort.getInput(), -1, null);
+ }
+ if (sort.offset != null) {
+ analyzeRex(sort.offset, sort.getInput(), -1, null);
+ }
+ 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<>();
+ List<CorrelFieldAccess> correlAccesses = new ArrayList<>();
+ rex.accept(new RexRefCollector(refs, subQueries, correlAccesses));
+ for (int refIndex : refs) {
+ if (leftCount >= 0 && refIndex < leftCount) {
+ traceColumnOrigin(leftInput, refIndex);
+ } else if (leftCount >= 0) {
+ traceColumnOrigin(inputNode, refIndex - leftCount);
+ } else {
+ traceColumnOrigin(inputNode, refIndex);
+ }
+ }
+ // Resolve correlated outer-column references. A RexFieldAccess over a
+ // RexCorrelVariable (e.g. $cor0.id inside a correlated subquery) always
+ // refers to an ENCLOSING subquery's row scope, never to inputNode itself
+ // (a reference to inputNode would be a plain RexInputRef).
+ //
+ // Registration happens inside RexRefCollector.visitFieldAccess with
+ // putIfAbsent(varName, enclosingScope), so by the time we reach here the
+ // scope for every $corN we just collected has been bound if possible.
+ // Lookup is a direct map get(); the uncommon case of an unbound variable
+ // falls back (safely over-approximating) to enclosingScope before giving
+ // up — tracing an extra scope is harmless and keeps us on the secure side
+ // of the check.
+ for (CorrelFieldAccess cfa : correlAccesses) {
+ RelNode scope = correlationScopeByVar.get(cfa.varName);
+ if (scope != null) {
+ traceColumnOrigin(scope, cfa.fieldIndex);
+ continue;
+ }
+ if (enclosingScope != null) {
+ logger.debug("Correlated reference '{}' was not pre-bound; "
+ + "falling back to current enclosingScope for a safe
over-approximation.",
+ cfa.varName);
+ traceColumnOrigin(enclosingScope, cfa.fieldIndex);
+ } else {
+ logger.warn("Unresolved correlated column reference '{}' (field index
{}): "
+ + "no enclosing correlation scope could be determined.
Column-level "
+ + "authorization may be incomplete for this reference.",
+ cfa.varName, cfa.fieldIndex);
+ }
+ }
+ 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);
+ // The subquery's $cor* variables reference the rows produced by
+ // inputNode (the input of the node whose expression contained this
+ // RexSubQuery). Make inputNode the active enclosing scope while we
+ // descend into sq.rel; save/restore the previous value so nesting and
+ // skip-level references behave correctly. Registration of new $corN
+ // names in correlationScopeByVar uses putIfAbsent, so a variable whose
+ // binding was established at a shallower scope is never overwritten.
+ RelNode prevEnclosing = enclosingScope;
+ enclosingScope = inputNode;
+ try {
+ // Recursively visit the subquery's RelNode tree so that RexInputRefs
+ // and correlated references inside the subquery are also collected
+ // and traced.
+ sq.rel.accept(this);
+ } finally {
+ enclosingScope = prevEnclosing;
+ }
+ }
+ }
+
+ /**
+ * Enforces column-level access for the given table and column set.
+ */
+ private void enforceColumnAccess(RelOptTable table, Set<String> columns) {
+ AccessAuthorizer authorizer =
AccessAuthorizerManager.getAuthorizer(drillConfig);
+ // When authorization is disabled the manager returns the NoOp authorizer,
+ // which allows all access — no enabled/disabled branching needed here.
+
+ // Resolve datasource / schema / table from the qualified name via the
+ // shared resolver, so column-level checks address exactly the same
+ // resource as the table-level checks in DrillCalciteCatalogReader. 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.
+ TableAccessResource resource =
TableAccessResource.resolve(table.getQualifiedName());
+ String userName = session.getCredentials().getUserName();
+
+ if (!authorizer.checkColumnAccess(UserIdentity.of(userName),
resource.getDataSource(),
+ resource.getSchemaPath(), resource.getTable(), columns,
AccessType.SELECT)) {
+ throw UserException.permissionError()
+ .message("Access denied: user '%s' lacks SELECT privilege on one or
more columns " +
+ "(%s) of table %s", userName, columns, resource)
+ .build(logger);
+ }
+ }
+
+ /**
+ * Holder for a correlated outer-column reference captured during Rex
+ * traversal: the variable name of the {@link RexCorrelVariable}
+ * (e.g. {@code "$cor0"}, used to do an exact lookup of the enclosing row
+ * scope via {@link #correlationScopeByVar}) and the index of the referenced
+ * field within that row scope.
+ */
+ private static final class CorrelFieldAccess {
+ final String varName;
+ final int fieldIndex;
+
+ CorrelFieldAccess(String varName, int fieldIndex) {
+ this.varName = varName;
+ this.fieldIndex = fieldIndex;
+ }
+ }
+
+ /**
+ * RexVisitor that collects all {@link RexInputRef} indices,
+ * {@link RexSubQuery} instances, and correlated outer-column references
+ * encountered in a {@link RexNode} tree. A correlated reference to an outer
+ * query column appears as a {@link RexFieldAccess} over a
+ * {@link RexCorrelVariable} (e.g. {@code $cor0.id}); such references are not
+ * {@link RexInputRef}s and would be silently skipped by a plain
+ * {@code RexInputRef}-only visitor, bypassing column-level authorization for
+ * the referenced outer column.
+ *
+ * <p>This collector is non-static so it can eagerly register each {@code
+ * $corN} in {@link #correlationScopeByVar} at the exact moment we first
+ * encounter it during Rex traversal. Registration is
+ * {@code putIfAbsent(name, enclosingScope)}, which guarantees we never
+ * overwrite a variable that was already bound at a shallower nesting level
+ * (which is exactly how skip-level references to outer variables stay
+ * correctly bound).
+ *
+ * <p>{@link RexSubQuery#accept(RexVisitor)} 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 final class RexRefCollector extends RexVisitorImpl<Void> {
+ private final Set<Integer> refs;
+ private final List<RexSubQuery> subQueries;
+ private final List<CorrelFieldAccess> correlAccesses;
+
+ RexRefCollector(Set<Integer> refs, List<RexSubQuery> subQueries,
+ List<CorrelFieldAccess> correlAccesses) {
+ super(true);
+ this.refs = refs;
+ this.subQueries = subQueries;
+ this.correlAccesses = correlAccesses;
+ }
+
+ @Override
+ public Void visitInputRef(RexInputRef ref) {
+ refs.add(ref.getIndex());
+ return null;
+ }
+
+ @Override
+ public Void visitSubQuery(RexSubQuery subQuery) {
+ subQueries.add(subQuery);
+ // Continue traversing operands (e.g. the left expression of `x IN
(...)`)
+ // to collect any RexInputRefs and nested RexSubQueries within them.
+ for (RexNode operand : subQuery.getOperands()) {
+ operand.accept(this);
+ }
+ return null;
+ }
+
+ @Override
+ public Void visitFieldAccess(RexFieldAccess fieldAccess) {
+ // A RexFieldAccess over a RexCorrelVariable is how Calcite represents a
+ // correlated reference to an outer query's column (e.g. $cor0.id inside
+ // a subquery). Such references are not RexInputRefs and would otherwise
+ // be silently skipped by this collector, bypassing column-level
+ // authorization for the referenced outer column.
+ RexNode refExpr = fieldAccess.getReferenceExpr();
+ if (refExpr instanceof RexCorrelVariable) {
+ RexCorrelVariable corVar = (RexCorrelVariable) refExpr;
+ String varName = corVar.getName();
+ // Eager, idempotent registration: putIfAbsent so the first
+ // enclosing scope we saw this variable under wins, and any later
+ // occurrences (possibly from a deeper nested scope after
+ // enclosingScope has been overwritten) do not clobber the binding.
+ if (enclosingScope != null) {
+ correlationScopeByVar.putIfAbsent(varName, enclosingScope);
Review Comment:
`putIfAbsent` binds the correlation variable to whichever scope is active
when it's first seen, which is only correct if each intermediate level also
references it.
```sql
SELECT a.pub FROM a WHERE EXISTS (
SELECT 1 FROM b WHERE EXISTS (
SELECT 1 FROM c WHERE c.k = a.secret AND c.j = b.x))
```
`$cor0` gets bound to `b`, so `a.secret`'s index is resolved against `b` and
never checked on `a`.
Fix: bind by `CorrelationId` at the node that defines it (the
`LogicalFilter`/`LogicalProject`/`LogicalJoin` whose `getVariablesSet()`
contains it, or the `LogicalCorrelate`), not on first use. Worth adding this
query as a test.
##########
auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControl.java:
##########
@@ -0,0 +1,279 @@
+/*
+ * 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.ranger.authorization.drill.authorizer;
+
+
+import org.apache.drill.exec.security.spi.AccessAuthorizer;
+import org.apache.drill.exec.security.spi.AccessType;
+import org.apache.drill.exec.security.spi.UserIdentity;
+import org.apache.hadoop.security.UserGroupInformation;
+import org.apache.ranger.authorization.drill.resource.DrillAccessType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Drill-facing authorization facade: implementation of the Drill
+ * {@link AccessAuthorizer} SPI backed by Ranger.
+ *
+ * <p>The class is instantiated once by the shim (drill-ranger-plugin-shim)
+ * via {@code DrillAccessControl(String serviceName)} — the constructor
+ * completes all initialization (or throws, fail-closed), mirroring Presto's
+ * {@code RangerPrestoAccessControl}. All access checks are instance methods
+ * invoked directly through the SPI interface.</p>
+ *
+ * <p>Group resolution: if the engine-supplied {@link UserIdentity} carries
+ * groups (resolved at authentication time), they are used as-is; otherwise
+ * the groups are resolved via Hadoop UGI ({@link #getUserGroups}).</p>
+ *
+ * <p>On Ranger evaluation error, checks return {@code false} (fail-closed).
+ * The service name identifies the Ranger service instance whose policies
+ * are evaluated.</p>
+ */
+public class DrillAccessControl implements AccessAuthorizer {
+
+ private static final Logger logger =
LoggerFactory.getLogger(DrillAccessControl.class);
+
+ private final DrillAuthorizer authorizer;
+
+ // Set of system schemas that bypass authorization (information_schema, sys,
etc.)
+ // Stored in uppercase; isSystemSchema() uppercases input before lookup so
the
+ // bypass is case-insensitive (e.g. "information_schema",
"INFORMATION_SCHEMA",
+ // "Sys", "SYS" all match).
+ private static final Set<String> SYSTEM_SCHEMAS = new
HashSet<>(Arrays.asList(
+ "INFORMATION_SCHEMA", "SYS"
+ ));
+
+ /**
+ * Creates and initializes the Ranger Drill plugin. Completes all
+ * initialization or throws (fail-closed) — the caller (the shim) must not
+ * receive a half-initialized authorizer.
+ *
+ * @param serviceName the Ranger service instance name (must match a service
created in Ranger Admin)
+ */
+ public DrillAccessControl(String serviceName) {
+ logger.info("Initializing Ranger Drill authorization plugin for service:
{}", serviceName);
+ try {
+ this.authorizer = new DrillAuthorizer(serviceName);
+ logger.info("Ranger Drill authorization plugin initialized
successfully");
+ } catch (Exception e) {
+ logger.error("Failed to initialize Ranger Drill plugin for service {}",
serviceName, e);
+ throw new RuntimeException(
+ "Failed to initialize Ranger Drill plugin — authorization disabled "
+ serviceName
+ + " with exception: " + e);
+ }
+ }
+
+ /**
+ * Package-private constructor for unit tests: injects a (mock) authorizer
+ * directly, bypassing Ranger Admin connectivity.
+ */
+ DrillAccessControl(DrillAuthorizer authorizer) {
+ this.authorizer = authorizer;
+ }
+
+ /**
+ * Resolves the OS-level groups for a given user via Hadoop UGI. Used only
+ * as a fallback when the engine-supplied {@link UserIdentity} carries no
+ * groups.
+ *
+ * @param user the username
+ * @return a set of group names (never null, empty on failure)
+ */
+ public static Set<String> getUserGroups(String user) {
+ if (user == null || user.trim().isEmpty()) {
+ return Collections.emptySet();
+ }
+ try {
+ UserGroupInformation ugi = UserGroupInformation.createRemoteUser(user);
+ String[] groups = ugi.getGroupNames();
+ return groups == null ? Collections.emptySet() : new
HashSet<>(Arrays.asList(groups));
+ } catch (Exception e) {
+ logger.warn("Failed to determine groups for user={}", user, e);
+ return Collections.emptySet();
+ }
+ }
+
+ /**
+ * Checks table-level access. The SPI {@link AccessType} is mapped to
+ * {@link DrillAccessType} by name; if the mapping fails (e.g. the SPI
+ * added a type this plugin does not know yet) access is denied
+ * (fail-closed).
+ *
+ * <p>System schemas ({@code INFORMATION_SCHEMA}, {@code sys}) bypass
+ * authorization. A null/empty schema is invalid input and fails closed
+ * rather than silently bypassing Ranger.</p>
+ *
+ * @param user the querying user identity
+ * @param dataSource the Drill storage plugin name (e.g. "dfs", "hbase")
+ * @param schema the schema path (e.g. "dfs.tmp")
+ * @param table the table name
+ * @param accessType the access type (e.g. SELECT, CREATE)
+ * @return {@code true} if access is allowed
+ */
+ @Override
+ public boolean checkTableAccess(UserIdentity user, String dataSource, String
schema,
+ String table, AccessType accessType) {
+ DrillAccessType operator = parseAccessType(accessType, user, schema,
table);
+ if (operator == null) {
+ return false;
+ }
+ Boolean bypass = systemSchemaBypass(user, schema, table);
+ if (bypass != null) {
+ return bypass;
+ }
+ try {
+ return authorizer.checkTableAccess(resolveIdentity(user), dataSource,
schema, table, operator);
+ } catch (Exception e) {
+ logger.error("Checking table access for user={}, schema={}, table={}.
with exception:{}",
+ user.getUser(), schema, table, e.toString());
+ return false; // fail-closed on error
+ }
+ }
+
+ /**
+ * Checks column-level access for a set of columns. Returns {@code true}
+ * only if the user has the specified access type on ALL given columns.
+ *
+ * <p>System schemas ({@code INFORMATION_SCHEMA}, {@code sys}) bypass
+ * authorization. A null/empty schema is invalid input and fails closed
+ * rather than silently bypassing Ranger.</p>
+ *
+ * @param user the querying user identity
+ * @param dataSource the Drill storage plugin name
+ * @param schema the schema path
+ * @param table the table name
+ * @param columns the set of column names to check
+ * @param accessType the access type (e.g. SELECT)
+ * @return {@code true} if access is allowed for every column
+ */
+ @Override
+ public boolean checkColumnAccess(UserIdentity user, String dataSource,
String schema,
+ String table, Set<String> columns, AccessType accessType) {
+ DrillAccessType operator = parseAccessType(accessType, user, schema,
table);
+ if (operator == null) {
+ return false;
+ }
+ Boolean bypass = systemSchemaBypass(user, schema, table);
+ if (bypass != null) {
+ return bypass;
+ }
+ try {
+ return authorizer.checkColumnAccess(resolveIdentity(user), dataSource,
schema, table,
+ columns, operator);
+ } catch (Exception e) {
+ logger.error("Error checking column access for user={}, schema={},
table={}",
+ user.getUser(), schema, table, e);
+ return false; // fail-closed on error
+ }
+ }
+
+ /**
+ * Maps the SPI {@link AccessType} to a {@link DrillAccessType} by name.
+ * Returns {@code null} (and logs) when the SPI enum carries a type this
+ * plugin does not know yet — callers deny access in that case
+ * (fail-closed). This guards against drift when a newer SPI adds access
+ * types before the Ranger service-def does.
+ */
+ private DrillAccessType parseAccessType(AccessType accessType, UserIdentity
user,
+ String schema, String table) {
+ try {
+ return DrillAccessType.valueOf(accessType.name());
+ } catch (Exception e) {
+ logger.error("Unsupported access type '{}', denied access for user={},
schema={}, table={}",
+ accessType, user.getUser(), schema, table);
+ return null;
+ }
+ }
+
+ /**
+ * Resolves the effective identity for an access check: engine-supplied
+ * groups are used as-is; when the identity carries none (e.g. mount points
+ * that only have the authenticated user name), the groups are resolved via
+ * Hadoop UGI ({@link #getUserGroups}).
+ */
+ private UserIdentity resolveIdentity(UserIdentity user) {
+ Set<String> groups = user.getGroups();
+ if (groups != null && !groups.isEmpty()) {
+ return user;
+ }
+ return UserIdentity.builder()
+ .setUser(user.getUser())
+ .setGroups(getUserGroups(user.getUser()))
+ .build();
+ }
+
+ /**
+ * Returns whether the given schema is a system schema that should bypass
+ * authorization.
+ *
+ * <p>Comparison is case-insensitive so that SQL like
+ * {@code SELECT * FROM information_schema.tables} (lowercase) or
+ * {@code SELECT * FROM SYS.DRILLBITS} (uppercase) both bypass authorization,
+ * matching Drill's own case-insensitive schema resolution.
+ *
+ * <p>For compound schema paths like {@code dfs.tmp}, only the top-level
segment
+ * (the storage plugin name) is checked — that is intentional, because system
+ * schemas ({@code INFORMATION_SCHEMA}, {@code sys}) are always top-level.
+ */
+ private static boolean isSystemSchema(String schema) {
+ if (schema == null || schema.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "Schema must not be null or empty for authorization check; refusing
to treat as system schema");
+ }
+ // Use only the top-level segment of a compound schema path
+ // (e.g. "dfs.tmp" -> "dfs", "INFORMATION_SCHEMA" -> "INFORMATION_SCHEMA")
+ String topLevel = schema;
+ int dot = schema.indexOf('.');
+ if (dot > 0) {
+ topLevel = schema.substring(0, dot);
+ }
+ return SYSTEM_SCHEMAS.contains(topLevel.toUpperCase());
Review Comment:
The schema passed here has the datasource stripped (`TableAccessResource`),
so `mysql.sys.*`, `pg.information_schema.*`, or a dfs workspace named `sys` all
bypass Ranger. MySQL ships a `sys` database by default.
Fix: pass the datasource into the bypass and match on it
(`SYSTEM_SCHEMAS.contains(dataSource.toUpperCase())`), not the schema.
##########
exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/ColumnAccessChecker.java:
##########
@@ -0,0 +1,511 @@
+/*
+ * 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.RelFieldCollation;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.RelShuttleImpl;
+import org.apache.calcite.rel.core.AggregateCall;
+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.RexCorrelVariable;
+import org.apache.calcite.rex.RexFieldAccess;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexSubQuery;
+import org.apache.calcite.rex.RexVisitor;
+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.rpc.user.UserSession;
+import org.apache.drill.exec.security.AccessAuthorizerManager;
+import org.apache.drill.exec.security.TableAccessResource;
+import org.apache.drill.exec.security.spi.AccessAuthorizer;
+import org.apache.drill.exec.security.spi.AccessType;
+import org.apache.drill.exec.security.spi.UserIdentity;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+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>Correlated subqueries are handled as well: a reference to an outer query
+ * column from inside a correlated subquery is represented by Calcite as a
+ * {@link RexFieldAccess} over a {@link RexCorrelVariable} (e.g. {@code
$cor0.id}),
+ * not as a {@link RexInputRef}. Each correlation variable carries a unique
+ * name {@code $corN} assigned by {@code SqlToRelConverter}; we keep an exact
+ * map from variable name to the enclosing {@link RelNode} row scope it refers
+ * to. The map entry is populated the FIRST time we see a given {@code $corN}
+ * during Rex traversal: we use {@code putIfAbsent(name, enclosingScope)} so
+ * that subsequent encounters (including cross-nested skip-level references
+ * from deeper subqueries to an outer {@code $cor0}) never overwrite the
+ * original, correct binding. This avoids the ambiguity of row-type-based
+ * heuristics: multiple enclosing scopes with identical schemas no longer
+ * cause false positives or missed detections, and no scope stack is needed —
+ * a single field tracks the innermost enclosing scope and is saved/restored
+ * around each subquery entry via try/finally.</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<>();
+
+ // The innermost enclosing row scope for correlated references. When we are
+ // about to descend into a RexSubQuery's Rel tree, we save the previous value
+ // and set this field to the outer-query inputNode that the subquery's
+ // $cor* variables are relative to; the saved value is restored in a finally
+ // block. Because variable → scope registration uses putIfAbsent in the Rex
+ // collector, a single field is sufficient even for deep nesting: skip-level
+ // references to an outer $corN reuse the binding that was established the
+ // first time that variable was seen (at a shallower enclosing scope).
+ private RelNode enclosingScope;
+
+ // Exact, name-keyed scope mapping: $cor0 → outer row scope, $cor1 → outer
+ // row scope, etc. Populated with putIfAbsent during Rex traversal so the
+ // first occurrence of each $corN establishes a permanent binding. Lookup is
+ // pure get(); a miss falls back (safely, in the over-approximate direction)
+ // to enclosingScope and emits a warning so unusual plans remain diagnosable.
+ private final Map<String, RelNode> correlationScopeByVar = new HashMap<>();
+
+ 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);
+ }
+ // AggregateCall arguments (the x in SUM(x)), FILTER (WHERE ...) columns,
+ // and WITHIN GROUP collation columns are not covered by getGroupSet().
+ // Trace them explicitly rather than relying on an implicit Project beneath
+ // the aggregate (plan shape can change across Calcite upgrades).
+ for (AggregateCall call : aggregate.getAggCallList()) {
+ for (int arg : call.getArgList()) {
+ traceColumnOrigin(aggregate.getInput(), arg);
+ }
+ if (call.filterArg >= 0) {
+ traceColumnOrigin(aggregate.getInput(), call.filterArg);
+ }
+ if (call.getCollation() != null) {
+ for (RelFieldCollation fc : call.getCollation().getFieldCollations()) {
+ traceColumnOrigin(aggregate.getInput(), fc.getFieldIndex());
+ }
+ }
+ }
+ return super.visit(aggregate);
+ }
+
+ @Override
+ public RelNode visit(LogicalSort sort) {
+ if (sort.getCollation() != null) {
+ sort.getCollation().getFieldCollations().forEach(fc ->
+ traceColumnOrigin(sort.getInput(), fc.getFieldIndex()));
+ }
+ // LIMIT/OFFSET are RexNode expressions that may carry RexSubQuery or
+ // RexInputRef (e.g. LIMIT (SELECT MAX(amount) FROM ...)). analyzeRex
+ // descends into them; without it, subqueries reachable only from Sort
+ // would never be column-checked.
+ if (sort.fetch != null) {
+ analyzeRex(sort.fetch, sort.getInput(), -1, null);
+ }
+ if (sort.offset != null) {
+ analyzeRex(sort.offset, sort.getInput(), -1, null);
+ }
+ 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<>();
+ List<CorrelFieldAccess> correlAccesses = new ArrayList<>();
+ rex.accept(new RexRefCollector(refs, subQueries, correlAccesses));
+ for (int refIndex : refs) {
+ if (leftCount >= 0 && refIndex < leftCount) {
+ traceColumnOrigin(leftInput, refIndex);
+ } else if (leftCount >= 0) {
+ traceColumnOrigin(inputNode, refIndex - leftCount);
+ } else {
+ traceColumnOrigin(inputNode, refIndex);
+ }
+ }
+ // Resolve correlated outer-column references. A RexFieldAccess over a
+ // RexCorrelVariable (e.g. $cor0.id inside a correlated subquery) always
+ // refers to an ENCLOSING subquery's row scope, never to inputNode itself
+ // (a reference to inputNode would be a plain RexInputRef).
+ //
+ // Registration happens inside RexRefCollector.visitFieldAccess with
+ // putIfAbsent(varName, enclosingScope), so by the time we reach here the
+ // scope for every $corN we just collected has been bound if possible.
+ // Lookup is a direct map get(); the uncommon case of an unbound variable
+ // falls back (safely over-approximating) to enclosingScope before giving
+ // up — tracing an extra scope is harmless and keeps us on the secure side
+ // of the check.
+ for (CorrelFieldAccess cfa : correlAccesses) {
+ RelNode scope = correlationScopeByVar.get(cfa.varName);
+ if (scope != null) {
+ traceColumnOrigin(scope, cfa.fieldIndex);
+ continue;
+ }
+ if (enclosingScope != null) {
+ logger.debug("Correlated reference '{}' was not pre-bound; "
+ + "falling back to current enclosingScope for a safe
over-approximation.",
+ cfa.varName);
+ traceColumnOrigin(enclosingScope, cfa.fieldIndex);
+ } else {
+ logger.warn("Unresolved correlated column reference '{}' (field index
{}): "
Review Comment:
`LogicalCorrelate` (LATERAL/UNNEST) isn't handled, so these references land
here and are only logged.
```sql
SELECT u.c FROM (SELECT pub, secret_arr FROM t WHERE pub > 0) x,
UNNEST(x.secret_arr) u(c)
```
returns `secret_arr` contents unchecked.
Fix: add a `visit(LogicalCorrelate)` that binds its `CorrelationId` to the
left input, and fail closed (throw) here instead of warning.
> 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)