diff --git a/auth/drill-ranger-plugin-shim/pom.xml b/auth/drill-ranger-plugin-shim/pom.xml
new file mode 100644
index 00000000000..52e3c20cd92
--- /dev/null
+++ b/auth/drill-ranger-plugin-shim/pom.xml
@@ -0,0 +1,99 @@
+
+
+
Non-SPI resources are returned unchanged so that Ranger's own + * resource lookups (configuration files, native libraries, etc.) are + * not affected.
+ */ + @Override + public EnumerationIf the URL cannot be read, conservatively returns {@code false} + * (keep the entry) to avoid accidentally dropping a legitimate SPI + * file that might be unreadable due to transient I/O conditions.
+ */ + private boolean declaresJersey3Multipart(URL url) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + // SPI files use FQNs (optionally with comment/whitespace); a plain + // contains() check is sufficient and avoids false negatives from + // edge-case formatting (trailing spaces, trailing comments). + if (line.contains(JERSEY3_MULTIPART_MARKER)) { + return true; + } + } + } catch (IOException e) { + logger.warn("Could not read SPI entry {} to inspect content; keeping it", url, e); + return false; + } + return false; + } +} diff --git a/auth/drill-ranger-plugin-shim/src/main/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizer.java b/auth/drill-ranger-plugin-shim/src/main/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizer.java new file mode 100644 index 00000000000..ee5b8ffe497 --- /dev/null +++ b/auth/drill-ranger-plugin-shim/src/main/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizer.java @@ -0,0 +1,242 @@ +/* + * 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.exec.security.spi.AccessAuthorizer; +import org.apache.drill.exec.security.spi.AccessType; +import org.apache.drill.exec.security.spi.UserIdentity; +import org.apache.ranger.plugin.classloader.RangerPluginClassLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Set; + +/** + * {@link AccessAuthorizer} SPI implementation backed by Ranger. + * + *This class is a thin shim on the Drillbit's main classpath. It creates + * the delegate {@code DrillAccessControl} (in the {@code drill-ranger-plugin} + * module) once via reflection, casts it to {@link AccessAuthorizer} and then + * invokes all checks as direct virtual calls through the SPI interface. + * The cast is safe because the plugin classloader is child-first but falls + * back to the Drillbit classloader for SPI types: both sides resolve + * {@code AccessAuthorizer}/{@code UserIdentity} to the same Class objects + * from drill-security-spi.jar on the main classpath.
+ * + *The classloader isolation is required because the Ranger plugin ships + * Jersey 2.35 ({@code org.glassfish.jersey.*} + {@code javax.ws.rs.*}) for + * {@code RangerAdminJersey2RESTClient}, while Drill's own REST server uses + * Jersey 3.1.9 ({@code org.glassfish.jersey.*} + {@code jakarta.ws.rs.*}). + * Both Jersey versions share the {@code org.glassfish.jersey.*} implementation + * package name but bind to incompatible API namespaces, so they cannot coexist + * in a single classloader. The {@link RangerPluginClassLoader} uses a + * child-first strategy to load plugin classes from its private URL list, + * falling back to the Drillbit classloader for shared types (Hadoop, SLF4J, + * the Drill security SPI, etc.).
+ * + *Drill uses the subclass {@link DrillRangerPluginClassLoader} instead of + * the base {@link RangerPluginClassLoader} to filter out the Jersey 3.1.9 + * {@code MultiPartFeatureAutodiscoverable} SPI entry that the base + * {@code findResources} merge would otherwise leak from the Drillbit + * classpath into Jersey 2.35's {@code ServiceFinder}. See + * {@link DrillRangerPluginClassLoader} for the root-cause analysis.
+ * + *Every delegated call is wrapped in + * {@code activateClassLoader()/deactivateClassLoader()} — the same + * per-call TCCL contract used by Presto's {@code RangerSystemAccessControl} + * and the other Ranger plugin shims.
+ */ +public class RangerAccessAuthorizer implements AccessAuthorizer { + + private static final Logger logger = LoggerFactory.getLogger(RangerAccessAuthorizer.class); + + private static final String RANGER_PLUGIN_TYPE = "drill"; + private static final String DRILL_ACCESS_CONTROL_CLASS = + "org.apache.ranger.authorization.drill.authorizer.DrillAccessControl"; + + private final RangerPluginClassLoader pluginClassLoader; + + // Delegate created once in the constructor; all checks are direct virtual + // calls through the SPI interface (no per-call reflection). Final: the + // constructor either assigns it or throws (fail-closed), so an instance + // that exists always has a working delegate. + private final AccessAuthorizer delegate; + + /** + * Creates the shim and completes all initialization (mirrors Presto's + * RangerSystemAccessControl constructor): acquires the + * {@link RangerPluginClassLoader}, reflectively instantiates + * {@code DrillAccessControl(serviceName)} from the isolated + * {@code ranger-drill-plugin-impl/} directory inside an + * activated-classloader block, and casts it to {@link AccessAuthorizer}. + * + * @param serviceName the Ranger service instance name + * @throws RuntimeException if initialization fails (fail-closed) + */ + public RangerAccessAuthorizer(String serviceName) { + this(createProductionClassLoader(), serviceName); + } + + /** + * Acquires the singleton production {@link DrillRangerPluginClassLoader}. + * Initialization failures (including {@link ExceptionInInitializerError} + * from the lazy holder) are wrapped in {@link RuntimeException} so the + * factory always surfaces a consistent fail-closed exception type. + */ + private static RangerPluginClassLoader createProductionClassLoader() { + try { + RangerPluginClassLoader cl = DrillRangerPluginClassLoaderHolder.INSTANCE; + logger.info("DrillRangerPluginClassLoader initialized for plugin type: {}", RANGER_PLUGIN_TYPE); + return cl; + } catch (Throwable t) { + logger.error("Failed to create DrillRangerPluginClassLoader", t); + throw new RuntimeException( + "Failed to create DrillRangerPluginClassLoader: " + t.getMessage(), t); + } + } + + /** + * Package-private constructor for unit tests. Allows injecting a mock + * {@link RangerPluginClassLoader} directly, bypassing + * {@link RangerPluginClassLoader#getInstance} — which Mockito cannot mock + * because it is a {@link ClassLoader} subclass (mocking class-loader + * statics risks infinite recursion). + * + *When a non-null classloader is supplied, the delegate is loaded and + * instantiated through the injected classloader instead of the production + * holder; the mock typically delegates {@code loadClass} to the test + * classloader so the test stub class (same FQCN) is instantiated.
+ */ + RangerAccessAuthorizer(RangerPluginClassLoader pluginClassLoader, String serviceName) { + try { + this.pluginClassLoader = pluginClassLoader; + activateClassLoader(); + try { + Class> clazz = pluginClassLoader.loadClass(DRILL_ACCESS_CONTROL_CLASS); + delegate = (AccessAuthorizer) clazz.getConstructor(String.class).newInstance(serviceName); + } finally { + deactivateClassLoader(); + } + } catch (Exception e) { + logger.error("Failed to initialize RangerAccessAuthorizer via PluginClassLoader", e); + throw new RuntimeException("Failed to initialize RangerAccessAuthorizer: " + e.getMessage(), e); + } + } + + /** + * Checks table-level access permission by delegating directly to the + * {@code DrillAccessControl} instance through the SPI interface. + * Fail-closed (returns {@code false}) on invocation error. + * + * @param user the querying user identity + * @param dataSource the data source name (StoragePlugin name, e.g. "dfs") + * @param schema the schema path (e.g. "dfs.tmp") + * @param table the table name + * @param accessType the access type (e.g. {@code AccessType.SELECT}) + * @return {@code true} if access is allowed + */ + @Override + public boolean checkTableAccess(UserIdentity user, String dataSource, String schema, + String table, AccessType accessType) { + activateClassLoader(); + try { + return delegate.checkTableAccess(user, dataSource, schema, table, accessType); + } catch (Exception e) { + logger.error("Failed to invoke DrillAccessControl.checkTableAccess()", e); + return false; // fail-closed on error + } finally { + deactivateClassLoader(); + } + } + + /** + * Checks column-level access permission by delegating directly to the + * {@code DrillAccessControl} instance through the SPI interface. + * Fail-closed (returns {@code false}) on invocation error. + * + * @param user the querying user identity + * @param dataSource the data source name (StoragePlugin name, e.g. "dfs") + * @param schema the schema path (e.g. "dfs.tmp") + * @param table the table name + * @param columns the set of column names being accessed + * @param accessType the access type (e.g. {@code AccessType.SELECT}) + * @return {@code true} if access is allowed for every column + */ + @Override + public boolean checkColumnAccess(UserIdentity user, String dataSource, String schema, + String table, SetRegistered via + * {@code META-INF/services/org.apache.drill.exec.security.spi.AccessAuthorizerFactory} + * and selected by {@code drill.exec.security.authorizer.name=ranger}. The + * config map carries the flattened {@code drill.exec.security.authorizer} + * subtree; Ranger-specific keys are parsed here (never in the engine): + * + *
{@link #createAuthorizer(Map)} returns a fully-initialized shim; + * initialization failures propagate as exceptions (fail-closed).
+ */ +public class RangerAccessAuthorizerFactory implements AccessAuthorizerFactory { + + public static final String NAME = "ranger"; + + static final String CONFIG_SERVICE_NAME = "service.name"; + static final String DEFAULT_SERVICE_NAME = "drill"; + + @Override + public String getName() { + return NAME; + } + + @Override + public AccessAuthorizer createAuthorizer(Map{@code RangerAccessAuthorizer} delegates to {@code DrillAccessControl} + * (in {@code drill-ranger-plugin}) through the {@link AccessAuthorizer} + * SPI interface. These tests verify the delegation by:
+ *Initialization happens in the constructor (mirroring Presto's + * RangerSystemAccessControl shim), so every test constructs the shim with a + * service name; the service name reaches the stub's constructor through the + * same reflective path used in production.
+ */ +public class RangerAccessAuthorizerTest { + + private static final String USER = "alice"; + private static final String DS = "mysql"; + private static final String SCHEMA = "shf"; + private static final String TABLE = "orders"; + + @Before + public void resetStub() { + DrillAccessControl.reset(); + } + + /** + * Builds a mock {@link RangerPluginClassLoader} whose {@code loadClass(String)} + * delegates to the test classloader. The reflective class lookup inside the + * shim constructor resolves the test stub class (DrillAccessControl) from + * the test classpath. + * + *{@code activate()} and {@code deactivate()} are no-ops on the mock + * (Mockito default behavior), which is exactly what we want — no TCCL + * switching during tests.
+ */ + private RangerPluginClassLoader mockPluginClassLoader() { + RangerPluginClassLoader mockCl = mock(RangerPluginClassLoader.class); + ClassLoader testCl = RangerAccessAuthorizerTest.class.getClassLoader(); + try { + when(mockCl.loadClass(anyString())).thenAnswer(inv -> { + String name = inv.getArgument(0); + return Class.forName(name, false, testCl); + }); + } catch (ClassNotFoundException e) { + // mockCl.loadClass() on a Mockito mock never actually throws; this + // catch is only to satisfy the compiler's checked-exception analysis. + throw new RuntimeException(e); + } + return mockCl; + } + + @Test + public void constructor_passesServiceNameToDrillAccessControl() { + RangerPluginClassLoader mockCl = mockPluginClassLoader(); + new RangerAccessAuthorizer(mockCl, "mySvc"); + assertEquals("mySvc", DrillAccessControl.lastServiceName); + } + + @Test + public void constructor_throwsRuntimeException_whenClassLoadingFails() { + // Simulate loadClass() failure — the constructor wraps it in a + // RuntimeException (fail-closed) instead of leaking checked exceptions. + RangerPluginClassLoader mockCl = mock(RangerPluginClassLoader.class); + try { + when(mockCl.loadClass(anyString())) + .thenThrow(new ClassNotFoundException("class not found boom")); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + + RuntimeException ex = assertThrows( + RuntimeException.class, () -> new RangerAccessAuthorizer(mockCl, "mySvc")); + assertTrue(ex.getMessage().contains("Failed to initialize RangerAccessAuthorizer")); + } + + @Test + public void constructor_throwsRuntimeException_whenDelegateConstructionFails() { + // Simulate DrillAccessControl initialization failure (e.g. Ranger Admin + // unreachable): the shim must surface a RuntimeException (fail-closed). + DrillAccessControl.constructFails = true; + RangerPluginClassLoader mockCl = mockPluginClassLoader(); + + RuntimeException ex = assertThrows( + RuntimeException.class, () -> new RangerAccessAuthorizer(mockCl, "mySvc")); + assertTrue(ex.getMessage().contains("Failed to initialize RangerAccessAuthorizer")); + } + + @Test + public void checkTableAccess_delegatesToDrillAccessControl() { + RangerPluginClassLoader mockCl = mockPluginClassLoader(); + DrillAccessControl.result = true; + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(mockCl, "mySvc"); + + assertTrue(authorizer.checkTableAccess( + UserIdentity.of(USER), DS, SCHEMA, TABLE, AccessType.SELECT)); + + assertEquals(USER, DrillAccessControl.lastUser.getUser()); + assertEquals(DS, DrillAccessControl.lastDataSource); + assertEquals(SCHEMA, DrillAccessControl.lastSchema); + assertEquals(TABLE, DrillAccessControl.lastTable); + assertEquals(AccessType.SELECT, DrillAccessControl.lastAccessType); + } + + @Test + public void checkTableAccess_returnsFalse_whenInvocationThrows() { + RangerPluginClassLoader mockCl = mockPluginClassLoader(); + DrillAccessControl.checkFailure = new RuntimeException("check boom"); + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(mockCl, "mySvc"); + + // fail-closed on error + assertFalse(authorizer.checkTableAccess( + UserIdentity.of(USER), DS, SCHEMA, TABLE, AccessType.SELECT)); + } + + @Test + public void checkColumnAccess_delegatesToDrillAccessControl() { + RangerPluginClassLoader mockCl = mockPluginClassLoader(); + DrillAccessControl.result = false; + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(mockCl, "mySvc"); + + SetMirrors the real class's instance-based SPI contract: constructor + * {@code DrillAccessControl(String serviceName)} completes initialization, + * then all checks arrive as {@link AccessAuthorizer} virtual calls. The + * static fields below let tests control construction failure, check results + * and check failures, and inspect the arguments the shim forwarded.
+ */ +public class DrillAccessControl implements AccessAuthorizer { + + /** Service name passed to the last constructor invocation. */ + public static String lastServiceName; + + /** When {@code true}, the constructor throws (simulates init failure). */ + public static boolean constructFails; + + /** Value returned by the check methods. */ + public static boolean result = true; + + /** When non-null, thrown by the check methods (fail-closed path). */ + public static RuntimeException checkFailure; + + /** Number of close() invocations (shutdown lifecycle). */ + public static int closeCount; + + // Arguments captured from the last check calls + public static UserIdentity lastUser; + public static String lastDataSource; + public static String lastSchema; + public static String lastTable; + public static AccessType lastAccessType; + public static SetThe 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.
+ * + *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}).
+ * + *On Ranger evaluation error, checks return {@code false} (fail-closed). + * The service name identifies the Ranger service instance whose policies + * are evaluated.
+ */ +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 SetSystem schemas ({@code INFORMATION_SCHEMA}, {@code sys}) bypass + * authorization. A null/empty schema is invalid input and fails closed + * rather than silently bypassing Ranger.
+ * + * @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. + * + *System schemas ({@code INFORMATION_SCHEMA}, {@code sys}) bypass + * authorization. A null/empty schema is invalid input and fails closed + * rather than silently bypassing Ranger.
+ * + * @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, SetComparison 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. + * + *
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()); + } + + /** + * Shared guard for the check methods: returns {@link Boolean#TRUE} for a + * system schema (bypass authorization), {@link Boolean#FALSE} for a + * malformed (null/empty) schema (fail closed — never silently bypass + * Ranger), or {@code null} when the check should proceed to the authorizer. + */ + private Boolean systemSchemaBypass(UserIdentity user, String schema, String table) { + try { + return isSystemSchema(schema) ? Boolean.TRUE : null; + } catch (IllegalArgumentException e) { + logger.error("Malformed (null/empty) schema in access check for user={}, table={}: {}", + user.getUser(), table, e.getMessage()); + return Boolean.FALSE; // malformed schema: fail closed + } + } + + /** + * Releases the Ranger plugin resources held by the underlying authorizer + * (policy-refresh threads, policy-engine caches). Forwarded from the shim + * ({@code RangerAccessAuthorizer}) when the Drillbit shuts down. Idempotent + * and safe if the plugin was never initialized. + */ + @Override + public void close() { + logger.info("Closing Ranger Drill authorization plugin"); + authorizer.close(); + } +} diff --git a/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizer.java b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizer.java new file mode 100644 index 00000000000..1805ad1a210 --- /dev/null +++ b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizer.java @@ -0,0 +1,178 @@ +/* + * 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.UserIdentity; +import org.apache.ranger.authorization.drill.resource.DrillAccessResource; +import org.apache.ranger.authorization.drill.resource.DrillAccessType; +import org.apache.ranger.authorization.drill.resource.DrillRangerAccessRequest; +import org.apache.ranger.plugin.policyengine.RangerAccessRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Optional; +import java.util.Set; + +/** + * Ranger-side authorization adapter: builds {@link RangerAccessRequest}s from + * plain check parameters and evaluates them against the Ranger policy engine. + * + *
Responsibilities: input validation (fail-closed on malformed arguments), + * per-column request iteration, and resource-matching-scope semantics — + * table-level checks use {@code SELF_OR_DESCENDANTS} so a table request can + * match column-level policies, column-level checks use {@code SELF} for exact + * column matching.
+ */ +public class DrillAuthorizer { + private static final Logger logger = LoggerFactory.getLogger(DrillAuthorizer.class); + private RangerBaseAuthorizer authorizer; + + public DrillAuthorizer(String serviceName) { + authorizer = RangerBaseAuthorizer.getInstance(); + authorizer.init(serviceName); + } + + /** + * Checks table-level access. + * + * @param user the querying user identity (with groups already resolved) + * @param dataSource the Drill storage plugin name (e.g. "dfs") + * @param schema the schema path (e.g. "dfs.tmp") + * @param table the table name + * @param operator the access type to check + * @return {@code true} if access is allowed; {@code false} on denial or + * malformed input (fail-closed) + */ + public boolean checkTableAccess(UserIdentity user, String dataSource, String schema, + String table, DrillAccessType operator) { + if (!validate(user, dataSource, schema, table)) { + logger.warn("Table access check denied: invalid arguments for user={}, datasource={}, schema={}, table={}", + user == null ? null : user.getUser(), dataSource, schema, table); + return false; + } + DrillAccessResource resource = new DrillAccessResource(dataSource, + Optional.ofNullable(schema), Optional.ofNullable(table)); + + // Table-level check uses SELF_OR_DESCENDANTS so a request without a column + // can still match column-level policies (column is a descendant of table). + // This allows a single policy with column=amount to authorize the table-level + // SELECT check that happens during SQL parsing (before columns are resolved). + boolean result = checkAccess(user, resource, operator, + RangerAccessRequest.ResourceMatchingScope.SELF_OR_DESCENDANTS); + if (logger.isDebugEnabled()) { + logger.debug("checkTableAccess result for user={}, datasource={}, schema={}, table={}, " + + "operator={}: result={}", + user.getUser(), dataSource, schema, table, operator.name(), result); + } + return result; + } + + /** + * Checks column-level access for a set of columns. Each column is checked + * individually (fail-fast on the first denial). + * + * @param user the querying user identity (with groups already resolved) + * @param dataSource the Drill storage plugin name (e.g. "dfs") + * @param schema the schema path (e.g. "dfs.tmp") + * @param table the table name + * @param columns the column names to check + * @param operator the access type to check + * @return {@code true} if access is allowed for every column; {@code false} + * on denial or malformed input (fail-closed) + */ + public boolean checkColumnAccess(UserIdentity user, String dataSource, String schema, + String table, SetInitialized once at Drillbit startup with the service name configured in + * {@code ranger-drill-security.xml}. After initialization, + * {@link #isAccessAllowed(RangerAccessRequest)} + * performs local in-memory policy evaluation (policies are pulled periodically by the + * {@code PolicyRefresher} background thread, identical to the Hive plugin).
+ */ +public class RangerBaseAuthorizer { + private static final Logger logger = LoggerFactory.getLogger(RangerBaseAuthorizer.class); + + private volatile RangerBasePlugin plugin; + + private RangerBaseAuthorizer() { + + } + + private static class LazyHolder { + private static final RangerBaseAuthorizer INSTANCE = new RangerBaseAuthorizer(); + } + + public static RangerBaseAuthorizer getInstance() { + return LazyHolder.INSTANCE; + } + + /** + * Initializes the Ranger plugin. The {@code serviceType} MUST be "drill" to match the + * service-def registered in Ranger Admin. + * + * @param serviceName the service instance name (matches {@code ranger.plugin.drill.service.name}) + */ + public synchronized void init(String serviceName) { + if (plugin != null) { + return; + } + try { + plugin = new RangerDrillPlugin(serviceName); + plugin.setResultProcessor(new RangerDefaultAuditHandler()); + plugin.init(); + logger.info("RangerPlugin initialized successfully for serviceName: {}", serviceName); + } catch (Exception e) { + plugin = null; + throw new RuntimeException("Failed to initialize RangerPlugin", e); + } + } + + public boolean isAccessAllowed(RangerAccessRequest request) { + if (plugin == null) { + logger.error("Plugin not initialized!"); + return false; + } + RangerAccessResult result = plugin.isAccessAllowed(request); + return result != null && result.getIsAllowed(); + } + + /** + * Forces an immediate policy refresh. Normally NOT needed — the {@code PolicyRefresher} + * background thread pulls policies periodically. Kept for administrative use only. + */ + public void refreshPoliciesNow() { + if (plugin != null) { + plugin.refreshPoliciesAndTags(); + } + } + + /** + * Releases the underlying {@link RangerBasePlugin} resources (policy-refresh + * threads, policy-engine caches) and clears the plugin reference. + * + *Idempotent and thread-safe ({@code synchronized}, mutually exclusive + * with {@link #init(String)}): calling {@code cleanUp()} without a prior + * {@code init()}, or twice in a row, is a safe no-op. After this method the + * instance is back to its pre-init state, so a subsequent {@code init()} + * re-creates a fresh plugin — this matters when a Drillbit is shut down and + * restarted inside the same JVM.
+ */ + public synchronized void cleanUp() { + if (plugin == null) { + return; + } + logger.info("Cleaning up RangerPlugin"); + try { + plugin.cleanup(); + } catch (Exception e) { + logger.warn("Error while cleaning up RangerPlugin", e); + } finally { + // Always drop the reference: even if cleanup() partially failed, a + // fresh plugin must be creatable on the next init(). + plugin = null; + } + } +} diff --git a/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerDrillPlugin.java b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerDrillPlugin.java new file mode 100644 index 00000000000..6bc9e9bca54 --- /dev/null +++ b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerDrillPlugin.java @@ -0,0 +1,32 @@ +/* + * 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.ranger.plugin.service.RangerBasePlugin; + +public class RangerDrillPlugin extends RangerBasePlugin { + /** + * The Ranger service type. MUST match {@code "name"} in ranger-servicedef-drill.json + * and {@code RangerDrillPlugin.SERVICE_TYPE}. + */ + public final static String SERVICE_TYPE = "drill"; + public final static String RANGER_DRILL_APPID = "drill"; + + public RangerDrillPlugin(String serviceName) { + super(SERVICE_TYPE, serviceName, RANGER_DRILL_APPID); + } +} diff --git a/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessResource.java b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessResource.java new file mode 100644 index 00000000000..095506649d2 --- /dev/null +++ b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessResource.java @@ -0,0 +1,87 @@ +/* + * 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.resource; + +import org.apache.ranger.plugin.policyengine.RangerAccessResourceImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Optional; + +public class DrillAccessResource extends RangerAccessResourceImpl { + + private static final Logger logger = LoggerFactory.getLogger(DrillAccessResource.class); + + public DrillAccessResource(MapThe tests operate on the real singleton and reset its plugin field before + * (and after) each test so state never leaks across cases. No Ranger Admin or + * network is involved: the plugin is either a Mockito mock injected via + * reflection, or a mock produced by {@link MockedConstruction} for the + * {@code init()} path.
+ */ +public class RangerBaseAuthorizerTest { + + private final RangerBaseAuthorizer authorizer = RangerBaseAuthorizer.getInstance(); + + @BeforeEach + @AfterEach + public void resetPluginField() throws Exception { + setPluginField(null); + } + + private void setPluginField(RangerDrillPlugin plugin) throws Exception { + Field f = RangerBaseAuthorizer.class.getDeclaredField("plugin"); + f.setAccessible(true); + f.set(authorizer, plugin); + } + + private RangerDrillPlugin getPluginField() throws Exception { + Field f = RangerBaseAuthorizer.class.getDeclaredField("plugin"); + f.setAccessible(true); + return (RangerDrillPlugin) f.get(authorizer); + } + + @Test + public void cleanUp_releasesPluginAndClearsField() throws Exception { + RangerDrillPlugin plugin = mock(RangerDrillPlugin.class); + setPluginField(plugin); + + authorizer.cleanUp(); + + verify(plugin).cleanup(); + assertNull(getPluginField(), + "cleanUp() must drop the plugin reference so a later init() can re-create it"); + } + + @Test + public void cleanUp_isIdempotent() throws Exception { + RangerDrillPlugin plugin = mock(RangerDrillPlugin.class); + setPluginField(plugin); + + authorizer.cleanUp(); + authorizer.cleanUp(); + + verify(plugin, times(1)).cleanup(); + assertNull(getPluginField()); + } + + @Test + public void cleanUp_withoutInit_isSafe() { + // Plugin field is null (never initialized, or already cleaned up): + // cleanUp() must be a safe no-op. + authorizer.cleanUp(); + } + + /** + * After a cleanUp() the plugin field is null, so the next init() must + * construct a fresh plugin instead of early-returning. Verified with a + * {@link MockedConstruction} so no real Ranger Admin connection is + * attempted. + */ + @Test + public void initAfterCleanUp_createsFreshPlugin() { + try (MockedConstructionDesign: 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.
+ * + *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.
+ * + *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.
+ * + *System schemas (INFORMATION_SCHEMA, sys) are bypassed inside the authorizer + * implementation. When authorization is disabled, the visitor is a no-op (fail-open).
+ */ +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 MapThis 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). + * + *
{@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.
+ */ + private final class RexRefCollector extends RexVisitorImplExtracts datasource/schema/table from the resolved qualified name via
+ * {@link TableAccessResource#resolve(List)} so it works for both DrillTable
+ * (native storage plugins) and non-DrillTable (JDBC storage plugin), and so
+ * table-level and column-level checks address exactly the same resource.
+ */
+ private void checkTableAccess(Prepare.PreparingTable table) {
+ if (!AccessAuthorizerManager.isEnabled(drillConfig)) {
+ // When authorization is disabled, skip the check entirely (matching the
+ // SqlConverter guard). This also avoids the UserSession.getCredentials()
+ // call below, which would NPE for sessions without credentials (e.g.
+ // mock sessions in planner unit tests).
+ return;
+ }
+ AccessAuthorizer authorizer = AccessAuthorizerManager.getAuthorizer(drillConfig);
+ // 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.
+ TableAccessResource resource = TableAccessResource.resolve(table.getQualifiedName());
+ String userName = session.getCredentials().getUserName();
+
+ if (!authorizer.checkTableAccess(UserIdentity.of(userName), resource.getDataSource(),
+ resource.getSchemaPath(), resource.getTable(), AccessType.SELECT)) {
+ throw UserException.permissionError()
+ .message("Access denied: user '%s' lacks SELECT privilege on %s",
+ userName, resource)
+ .build(logger);
+ }
+ }
+
private void checkTemporaryTable(List The created instance is cached in a lazy double-checked-locking singleton;
+ * mount points ({@code DrillCalciteCatalogReader}, {@code ColumnAccessChecker})
+ * retrieve it via {@link #getAuthorizer(DrillConfig)}. The Drillbit triggers
+ * initialization eagerly at startup for fail-fast behavior. Called by {@code Drillbit.close()} during shutdown, after all
+ * in-flight queries have drained: the authorizer may hold plugin resources
+ * (e.g. Ranger policy-refresh threads, policy-engine caches) that must be
+ * released. Idempotent and safe when authorization is disabled or never
+ * initialized — {@link AllowAllAccessAuthorizer} performs no work, and the
+ * default SPI {@code close()} is a no-op. Close failures are logged and
+ * ignored so a failing authorizer never aborts Drill shutdown. Mount points that pay measurable setup cost before the first SPI call
+ * (e.g. the column-level check walks the whole RelNode tree) consult this
+ * up front and skip the work entirely when authorization is disabled,
+ * instead of relying on the {@link AllowAllAccessAuthorizer} sentinel
+ * short-circuiting inside each check. Statements that go through the Calcite catalog reader (queries, and the
+ * query part of CTAS / CREATE VIEW) are checked in
+ * {@code DrillCalciteCatalogReader}. DDL statements address their target
+ * object directly through the schema, so the check is issued here at the
+ * handler layer. Resource mapping reuses {@link TableAccessResource#resolve(List)} so
+ * table-level, column-level and DDL checks address exactly the same resource
+ * for the same table. No-op when authorization is disabled (the manager
+ * returns the allow-all authorizer). Authorization happens before the existence check ("table not found")
+ * so that an unauthorized user cannot probe object existence through
+ * differing error messages. Use {@link #resolve(List The first segment of a qualified name is the datasource (storage
+ * plugin name), the last segment is the table, and any segments in between
+ * form the schema path. The schema MUST NOT include the datasource prefix,
+ * otherwise policy matching fails (policy has {@code schema=shf} but the
+ * request would send {@code schema=mysql.shf}). Some backends have no schema concept (e.g. a flat file store). To keep
+ * the four-level model uniform, a default schema is synthesized per
+ * datasource via {@link #getDefaultSchemaByDataSource(String)}. 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. These tests DO NOT start a Drillbit, load any storage plugin, or require
+ * Ranger to be enabled. They build a Calcite RelNode tree directly and feed
+ * it to {@code ColumnAccessChecker.check()}. When Ranger is disabled (the
+ * default), {@code AccessAuthorizerManager} returns a no-op authorizer, so no
+ * real enforcement decision is made. Instead, we inspect the bookkeeping map
+ * {@code ColumnAccessChecker.tableToReferencedCols} via reflection after the
+ * visit completes. That map records origin table-column indices for every
+ * column reference the checker discovered, including references traced
+ * through correlated outer-column resolution.
+ *
+ * Covered scenarios: Uses the {@link TestAccessAuthorizerFactory} (name "test", registered via
+ * the test META-INF/services file) to verify ServiceLoader-based factory
+ * discovery and config flattening without requiring the production Ranger
+ * shim's {@code RangerPluginClassLoader} infrastructure. Also provides test knobs for authorization checks:
+ *
+ *
+ *
+ *
+ *
+ */
+public class ColumnAccessCheckerCorrelatedTest extends BaseTest {
+
+ // Column indices for both test tables. The two tables share an identical
+ // field layout (same field names at the same ordinal positions) so that any
+ // row-type-based heuristic would be unable to distinguish them; an
+ // exact-name binding has no ambiguity here.
+ //
+ // orders: [id(0 INT), amount(1 DECIMAL), user_id(2 INT), active(3 BOOLEAN)]
+ // customers:[id(0 INT), name(1 VARCHAR), user_id(2 INT)]
+ private static final int COL_ID = 0;
+ private static final int COL_USER_ID = 2;
+
+ private RelOptCluster cluster;
+ private RexBuilder rexBuilder;
+ private SqlTypeFactoryImpl typeFactory;
+ private RelOptTable ordersTable;
+ private RelOptTable customersTable;
+
+ @Before
+ public void setUp() {
+ typeFactory = new SqlTypeFactoryImpl(
+ org.apache.calcite.rel.type.RelDataTypeSystem.DEFAULT);
+ rexBuilder = new RexBuilder(typeFactory);
+
+ VolcanoPlanner planner = new VolcanoPlanner();
+ planner.addRelTraitDef(ConventionTraitDef.INSTANCE);
+ planner.addRelTraitDef(RelCollationTraitDef.INSTANCE);
+
+ cluster = RelOptCluster.create(planner, rexBuilder);
+ cluster.setMetadataQuerySupplier(RelMetadataQuery::instance);
+
+ ordersTable = new StubRelOptTable(
+ ImmutableList.of("cp", "default", "orders"),
+ buildOrdersRowType(),
+ typeFactory);
+ customersTable = new StubRelOptTable(
+ ImmutableList.of("cp", "default", "customers"),
+ buildCustomersRowType(),
+ typeFactory);
+ }
+
+ private RelDataType buildOrdersRowType() {
+ return typeFactory.builder()
+ .add("id", typeFactory.createSqlType(SqlTypeName.INTEGER))
+ .add("amount", typeFactory.createSqlType(SqlTypeName.DECIMAL))
+ .add("user_id", typeFactory.createSqlType(SqlTypeName.INTEGER))
+ .add("active", typeFactory.createSqlType(SqlTypeName.BOOLEAN))
+ .build();
+ }
+
+ private RelDataType buildCustomersRowType() {
+ return typeFactory.builder()
+ .add("id", typeFactory.createSqlType(SqlTypeName.INTEGER))
+ .add("name", typeFactory.createSqlType(SqlTypeName.VARCHAR, 64))
+ .add("user_id", typeFactory.createSqlType(SqlTypeName.INTEGER))
+ .build();
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ Field f = Class.forName(
+ "org.apache.drill.exec.security.AccessAuthorizerManager")
+ .getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+
+ // ------------------------------------------------------------------
+ // Helpers
+ // ------------------------------------------------------------------
+
+ @SuppressWarnings("unchecked")
+ private Map
+ * SELECT o.id
+ * FROM orders o
+ * WHERE EXISTS (
+ * SELECT 1 FROM customers c
+ * WHERE c.user_id = o.user_id
+ * );
+ *
+ *
+ * The outer SELECT projects only {@code o.id} (column 0). The inner
+ * predicate references {@code o.user_id} (column 2) via a correlated
+ * variable {@code $cor0.user_id}. Before the fix,
+ * {@code RexFieldAccess(RexCorrelVariable)} was silently skipped so
+ * orders.user_id would be missing from the traced column set — a classic
+ * column-level bypass.
+ */
+ @Test
+ public void correlatedExistsRefsUnprojectedOuterColumn() throws Exception {
+ RelNode ordersScan = LogicalTableScan.create(cluster, ordersTable, Collections.emptyList());
+ CorrelationId corr0Id = cluster.createCorrel();
+ RelNode customersScan = LogicalTableScan.create(cluster, customersTable, Collections.emptyList());
+
+ RexCorrelVariable cor0 = (RexCorrelVariable)
+ rexBuilder.makeCorrel(ordersTable.getRowType(), corr0Id);
+ RexNode outerUserId = rexBuilder.makeFieldAccess(cor0, COL_USER_ID);
+ RexNode innerUserId = rexBuilder.makeInputRef(customersScan, COL_USER_ID);
+ RexNode innerCond = rexBuilder.makeCall(SqlStdOperatorTable.EQUALS,
+ innerUserId, outerUserId);
+ RelNode filteredCustomers = LogicalFilter.create(customersScan, innerCond);
+ RelNode innerProject = LogicalProject.create(filteredCustomers,
+ Collections.emptyList(),
+ Collections.singletonList(rexBuilder.makeExactLiteral(BigDecimal.ONE)),
+ intLiteralRowType(typeFactory));
+
+ RexSubQuery existsSq = RexSubQuery.exists(innerProject);
+ RelNode filteredOrders = LogicalFilter.create(ordersScan, existsSq);
+
+ RelNode outerProject = LogicalProject.create(filteredOrders,
+ Collections.emptyList(),
+ Collections.singletonList(rexBuilder.makeInputRef(filteredOrders, COL_ID)),
+ typeFactory.createStructType(
+ Collections.singletonList(typeFactory.createSqlType(SqlTypeName.INTEGER)),
+ Collections.singletonList("id")));
+
+ Map
+ * SELECT o.id
+ * FROM orders o
+ * WHERE EXISTS (
+ * SELECT 1 FROM customers c
+ * WHERE c.id = o.id
+ * AND EXISTS (
+ * SELECT 1 FROM customers c2
+ * WHERE c2.user_id = o.user_id -- skip-level correlation
+ * )
+ * );
+ *
+ *
+ * The innermost subquery references the outermost scope's user_id column.
+ * Middle and outer scopes share an identical row schema, so a row-type-based
+ * scope resolver would be ambiguous. The exact-name binding implemented in
+ * ColumnAccessChecker remembers the $corN → scope mapping captured at
+ * first encounter and never overwrites it for skip-level reuses.
+ */
+ @Test
+ public void nestedExistsSkipLevelCorrelation_preservesExactBinding() throws Exception {
+ RelNode ordersScan = LogicalTableScan.create(cluster, ordersTable, Collections.emptyList());
+
+ CorrelationId corr0Id = cluster.createCorrel();
+ CorrelationId corr1Id = cluster.createCorrel();
+ RexCorrelVariable cor0 = (RexCorrelVariable)
+ rexBuilder.makeCorrel(ordersTable.getRowType(), corr0Id);
+
+ // Innermost: c2.user_id = $cor0.user_id (SKIP LEVEL — not $cor1)
+ RelNode c2Scan = LogicalTableScan.create(cluster, customersTable, Collections.emptyList());
+ RexNode c2UserId = rexBuilder.makeInputRef(c2Scan, COL_USER_ID);
+ RexNode skipOuterUserId = rexBuilder.makeFieldAccess(cor0, COL_USER_ID);
+ RexNode innerCond = rexBuilder.makeCall(SqlStdOperatorTable.EQUALS,
+ c2UserId, skipOuterUserId);
+ RelNode filteredC2 = LogicalFilter.create(c2Scan, innerCond);
+ RelNode innerProject = LogicalProject.create(filteredC2,
+ Collections.emptyList(),
+ Collections.singletonList(rexBuilder.makeExactLiteral(BigDecimal.ONE)),
+ intLiteralRowType(typeFactory));
+ RexSubQuery innerExists = RexSubQuery.exists(innerProject);
+
+ // Middle customers: c.id = $cor0.id AND EXISTS(...)
+ RelNode cScan = LogicalTableScan.create(cluster, customersTable, Collections.emptyList());
+ RexNode cId = rexBuilder.makeInputRef(cScan, COL_ID);
+ RexNode outerIdRef = rexBuilder.makeFieldAccess(cor0, COL_ID);
+ RexNode firstCond = rexBuilder.makeCall(SqlStdOperatorTable.EQUALS,
+ cId, outerIdRef);
+ RexNode middleCond = rexBuilder.makeCall(SqlStdOperatorTable.AND,
+ firstCond, innerExists);
+ RelNode filteredC = LogicalFilter.create(cScan, middleCond);
+ RelNode middleProject = LogicalProject.create(filteredC,
+ Collections.emptyList(),
+ Collections.singletonList(rexBuilder.makeExactLiteral(BigDecimal.ONE)),
+ intLiteralRowType(typeFactory));
+ RexSubQuery outerExists = RexSubQuery.exists(middleProject);
+
+ RelNode filteredOrders = LogicalFilter.create(ordersScan, outerExists);
+ RelNode outerProject = LogicalProject.create(filteredOrders,
+ Collections.emptyList(),
+ Collections.singletonList(rexBuilder.makeInputRef(filteredOrders, COL_ID)),
+ typeFactory.createStructType(
+ Collections.singletonList(typeFactory.createSqlType(SqlTypeName.INTEGER)),
+ Collections.singletonList("id")));
+
+ Map
+ * SELECT SUM(amount) FILTER (WHERE active) FROM orders
+ *
+ *
+ * The aggregate has no GROUP BY and one AggregateCall SUM(amount) with a
+ * FILTER clause referencing the boolean column active (filterArg=3).
+ * Before the fix, only {@code getGroupSet()} was traced (empty here), so
+ * neither amount (argList) nor active (filterArg) would be recorded. The
+ * fix traces {@code getAggCallList()} args, filterArg, and collation
+ * explicitly.
+ */
+ @Test
+ public void aggregateCallArgsAndFilterAreTraced() throws Exception {
+ RelNode ordersScan = LogicalTableScan.create(cluster, ordersTable, Collections.emptyList());
+
+ // SUM(amount): argList=[1], filterArg=3 (active BOOLEAN NOT NULL column)
+ AggregateCall sumCall = AggregateCall.create(
+ SqlStdOperatorTable.SUM,
+ false, // distinct
+ false, // approximate
+ false, // ignoreNulls
+ ImmutableList.of(1), // argList: amount
+ 3, // filterArg: active (BOOLEAN NOT NULL)
+ null, // distinctKeys
+ RelCollations.EMPTY, // collation
+ typeFactory.createTypeWithNullability(
+ typeFactory.createSqlType(SqlTypeName.DECIMAL), true),
+ "SUM(amount)");
+
+ RelNode aggregate = LogicalAggregate.create(ordersScan,
+ ImmutableBitSet.of(), // groupSet: empty (no GROUP BY)
+ ImmutableList.of(ImmutableBitSet.of()), // groupSets
+ Collections.singletonList(sumCall));
+
+ Map
+ * SELECT * FROM orders ORDER BY id LIMIT
+ *
+ * The LIMIT (fetch) expression is a RexNode that may carry column references
+ * or subqueries. Before the fix, {@code visit(LogicalSort)} only traced the
+ * ORDER BY collation and never called {@code analyzeRex} on fetch/offset,
+ * so any column reachable only from LIMIT/OFFSET was silently skipped.
+ */
+ @Test
+ public void sortFetchExpressionIsAnalyzed() throws Exception {
+ RelNode ordersScan = LogicalTableScan.create(cluster, ordersTable, Collections.emptyList());
+
+ // ORDER BY id (column 0); LIMIT references amount (column 1).
+ // Without the fix, only column 0 (from collation) would be traced;
+ // column 1 (from fetch) would be missed.
+ RexNode fetch = rexBuilder.makeInputRef(ordersScan, 1);
+ RelNode sort = LogicalSort.create(ordersScan,
+ RelCollations.of(0), // ORDER BY id
+ fetch,
+ null); // no offset
+
+ Map
+ *
+ * All state is static so a single instance created via the manager serves a
+ * whole embedded Drillbit; call {@link #reset()} between tests.
Prerequisites: These tests require a live Drill cluster with: + *
Why CTEs don't need special handling: Calcite's + * {@code SqlToRelConverter} inlines CTE definitions into the RelNode tree + * before {@code ColumnAccessChecker} runs. After inlining, every + * {@code TableScan} seen by the {@code RelShuttle} is a real underlying table, + * so column-level checks apply uniformly regardless of whether the original + * SQL used a CTE or a direct {@code SELECT}.
+ * + *Tests are {@code @Ignore}d by default because they depend on external + * resources. Remove {@code @Ignore} when running against a configured + * Ranger + MySQL environment.
+ */ +@Category(SqlTest.class) +@Ignore("Requires Ranger authorization enabled with MySQL storage plugin and sample policies") +public class TestWithClauseRangerAuthz extends BaseTestQuery { + //private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(TestWithClauseRangerAuthz.class); + + private static final String ACCESS_DENIED = UserBitShared.DrillPBError.ErrorType.PERMISSION.name(); + + /** + * DENY: CTE body uses {@code SELECT *} which expands to all columns including + * {@code order_date} (not in Policy B). + */ + @Test + public void deny_cteSelectStarFromOrders() throws Exception { + String query = "WITH t AS (SELECT * FROM mysql.shf.orders)\n" + + "SELECT * FROM t"; + errorMsgTestHelper(query, ACCESS_DENIED); + } + + /** + * PASS: CTE body only selects {@code id} (in Policy B); outer query selects + * from the CTE, which resolves to the same authorized column. + */ + @Test + public void pass_cteSelectAuthorizedColumn() throws Exception { + String query = "WITH t AS (SELECT id FROM mysql.shf.orders)\n" + + "SELECT * FROM t"; + test(query); + } + + /** + * DENY: CTE body selects {@code order_date} (not in Policy B). Even though + * the outer query projects {@code order_date} from the CTE, the column + * access check traces back to the underlying {@code orders} table scan. + */ + @Test + public void deny_cteSelectUnauthorizedColumnProjected() throws Exception { + String query = "WITH t AS (SELECT id, order_date FROM mysql.shf.orders)\n" + + "SELECT order_date FROM t"; + errorMsgTestHelper(query, ACCESS_DENIED); + } + + /** + * DENY: The CTE body references {@code order_date} (not in Policy B) even + * though the outer query only projects {@code id}. After CTE inlining, the + * {@code TableScan} for {@code orders} has both {@code id} and + * {@code order_date} referenced, so the check fails on {@code order_date}. + */ + @Test + public void deny_cteBodyReferencesUnauthorizedColumnEvenIfOuterDoesNot() throws Exception { + String query = "WITH t AS (SELECT id, order_date FROM mysql.shf.orders)\n" + + "SELECT id FROM t"; + errorMsgTestHelper(query, ACCESS_DENIED); + } + + /** + * PASS: Multiple CTEs referencing different tables. CTE {@code a} selects + * {@code id} from {@code orders} (Policy B); CTE {@code b} selects {@code id} + * from {@code users} (Policy A via {@code *}). Outer query selects from + * {@code b} only. All referenced columns are authorized. + */ + @Test + public void pass_multipleCtesDifferentTables() throws Exception { + String query = "WITH a AS (SELECT id FROM mysql.shf.orders),\n" + + " b AS (SELECT id FROM mysql.shf.users)\n" + + "SELECT * FROM b"; + test(query); + } + + // ------------------------------------------------------------------ + // Table alias tests — verify that column-level authorization is + // transparent to table aliases. Calcite resolves aliases during + // SqlToRel conversion; RexInputRef indexes point to row-type + // positions (not alias names), and RelMetadataQuery.getColumnOrigins + // traces through to the underlying TableScan, so ColumnAccessChecker + // sees the real table/column regardless of any alias used in SQL. + // ------------------------------------------------------------------ + + /** + * DENY: Table alias on an unauthorized column. {@code o.order_date} resolves + * to {@code order_date} of {@code orders} (not in Policy B). + */ + @Test + public void deny_simpleAliasUnauthorizedColumn() throws Exception { + String query = "SELECT o.order_date FROM mysql.shf.orders o"; + errorMsgTestHelper(query, ACCESS_DENIED); + } + + /** + * PASS: Join with aliases on two tables. {@code a.id} resolves to + * {@code users.id} (Policy A via {@code *}); {@code b.id} resolves to + * {@code orders.id} (Policy B). Join condition columns also authorized. + */ + @Test + public void pass_joinWithAliases() throws Exception { + String query = "SELECT a.id FROM mysql.shf.users a " + + "JOIN mysql.shf.orders b ON a.id = b.id"; + test(query); + } + + /** + * PASS: Simple table alias on an authorized column. {@code o.id} resolves + * to the {@code id} column of {@code orders} (in Policy B). + */ + @Test + public void pass_simpleAliasAuthorizedColumn() throws Exception { + String query = "SELECT o.id FROM mysql.shf.orders o"; + test(query); + } + + /** + * PASS: Alias used in WHERE clause on an authorized column. + * {@code o.amount} resolves to {@code orders.amount} (in Policy B). + */ + @Test + public void pass_aliasInWhereAuthorizedColumn() throws Exception { + String query = "SELECT o.id FROM mysql.shf.orders o WHERE o.amount = '150.0'"; + test(query); + } +} diff --git a/exec/java-exec/src/test/resources/META-INF/services/org.apache.drill.exec.security.spi.AccessAuthorizerFactory b/exec/java-exec/src/test/resources/META-INF/services/org.apache.drill.exec.security.spi.AccessAuthorizerFactory new file mode 100644 index 00000000000..220e6b84e0c --- /dev/null +++ b/exec/java-exec/src/test/resources/META-INF/services/org.apache.drill.exec.security.spi.AccessAuthorizerFactory @@ -0,0 +1,15 @@ +# 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. +org.apache.drill.exec.security.TestAccessAuthorizerFactory \ No newline at end of file diff --git a/exec/java-exec/src/test/resources/core-site.xml b/exec/java-exec/src/test/resources/core-site.xml index 0392da89148..0dc5b65eafa 100644 --- a/exec/java-exec/src/test/resources/core-site.xml +++ b/exec/java-exec/src/test/resources/core-site.xml @@ -51,4 +51,12 @@Mirrors Presto's {@code SystemAccessControl}: the engine (via + * {@code AccessAuthorizerManager}) discovers an {@link AccessAuthorizerFactory} + * through {@code ServiceLoader} and calls {@code factory.createAuthorizer(config)} to + * obtain a fully-initialized instance. Implementations must complete all + * initialization in the factory/constructor phase; there is no separate + * {@code init()} lifecycle method.
+ * + *This interface and its parameter types ({@link UserIdentity}, strings, + * sets) depend only on the JDK, so implementations can live outside the + * Drill engine (e.g. an Apache Ranger plugin) with no dependency beyond + * this small SPI module — analogous to Presto's {@code presto-spi}.
+ * + *There is deliberately no {@code isEnabled()} method: an instance that + * exists is initialized and active (the factory completes initialization or + * throws, fail-closed). Whether authorization is enabled at all is an + * engine-side configuration concern — the engine selects this SPI or its own + * allow-all implementation (mirroring Presto's + * {@code SystemAccessControl} / {@code AllowAllAccessControl}).
+ * + *Access types are identified by the {@link AccessType} enum (mirroring + * Presto's {@code Privilege}). Callers pass the constant to + * {@link #checkTableAccess} or {@link #checkColumnAccess}. This avoids a + * dedicated method per access type and keeps the interface stable as new + * operations are added.
+ */ +public interface AccessAuthorizer { + + /** + * Checks table-level access permission. + * + * @param user the querying user identity + * @param dataSource the data source name (StoragePlugin name, e.g. "dfs") + * @param schema the schema path (e.g. "dfs.tmp") + * @param table the table name + * @param accessType the access type (e.g. {@link AccessType#SELECT}, + * {@link AccessType#CREATE}) + * @return {@code true} if access is allowed + */ + boolean checkTableAccess(UserIdentity user, String dataSource, String schema, + String table, AccessType accessType); + + /** + * Checks column-level access permission for a set of columns. Returns + * {@code true} only if the user has the specified access type on ALL given + * columns. + * + * @param user the querying user identity + * @param dataSource the data source name (StoragePlugin name, e.g. "dfs") + * @param schema the schema path (e.g. "dfs.tmp") + * @param table the table name + * @param columns the set of column names being accessed + * @param accessType the access type (e.g. {@link AccessType#SELECT}) + * @return {@code true} if access is allowed for every column + */ + boolean checkColumnAccess(UserIdentity user, String dataSource, String schema, + String table, SetDefault no-op: implementations that hold no resources do not need to + * override. Implementations should be idempotent and must not throw checked + * exceptions — the engine logs and ignores close failures rather than + * aborting shutdown.
+ */ + default void close() { + // no-op by default + } +} diff --git a/exec/security-spi/src/main/java/org/apache/drill/exec/security/spi/AccessAuthorizerFactory.java b/exec/security-spi/src/main/java/org/apache/drill/exec/security/spi/AccessAuthorizerFactory.java new file mode 100644 index 00000000000..9b3f1c438f8 --- /dev/null +++ b/exec/security-spi/src/main/java/org/apache/drill/exec/security/spi/AccessAuthorizerFactory.java @@ -0,0 +1,54 @@ +/* + * 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.spi; + +import java.util.Map; + +/** + * Factory SPI for creating {@link AccessAuthorizer} instances. + * + *Mirrors Presto's {@code SystemAccessControlFactory}. Implementations are + * discovered via {@code ServiceLoader} (registered under + * {@code META-INF/services/org.apache.drill.exec.security.spi.AccessAuthorizerFactory}) + * and selected by the {@code drill.exec.security.authorizer.name} configuration + * key matching {@link #getName()}.
+ * + *The {@code config} map carries the flattened properties of the + * {@code drill.exec.security.authorizer} configuration subtree (with + * {@code enabled} and {@code name} already removed). It contains only JDK + * types so implementations never depend on Drill engine configuration + * classes — this keeps an authorization plugin (e.g. the Ranger plugin) + * portable to external repositories.
+ */ +public interface AccessAuthorizerFactory { + + /** + * @return the factory name matched against + * {@code drill.exec.security.authorizer.name} (e.g. "ranger") + */ + String getName(); + + /** + * Creates a fully-initialized {@link AccessAuthorizer}. All initialization + * (classloader setup, policy engine bootstrap, ...) must complete here or + * by throwing — there is no separate init lifecycle on the authorizer. + * + * @param config flattened authorizer configuration properties; never {@code null} + * @return a ready-to-use authorizer instance + */ + AccessAuthorizer createAuthorizer(MapThe enum keeps the set of access types a closed, compile-time-checked + * vocabulary: a typo like {@code "SELEC"} is a compile error instead of a + * run-time denial. Adding a constant is binary-compatible (implementations + * keep working; unknown values are denied fail-closed by implementations + * such as the Ranger plugin, which maps this enum to its own + * {@code DrillAccessType} by name).
+ * + *The Drill engine issues {@link #SELECT} for table- and column-level + * checks during SQL validation, and {@link #CREATE} / {@link #DROP} for DDL + * authorization (CREATE TABLE / CTAS, CREATE VIEW from the DDL handlers; + * DROP TABLE / DROP VIEW from their handlers). Temporary tables are + * session-scoped and bypass these checks.
+ */ +public enum AccessType { + SELECT, + CREATE, + DROP +} diff --git a/exec/security-spi/src/main/java/org/apache/drill/exec/security/spi/UserIdentity.java b/exec/security-spi/src/main/java/org/apache/drill/exec/security/spi/UserIdentity.java new file mode 100644 index 00000000000..70e1edd348d --- /dev/null +++ b/exec/security-spi/src/main/java/org/apache/drill/exec/security/spi/UserIdentity.java @@ -0,0 +1,117 @@ +/* + * 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.spi; + +import java.security.Principal; +import java.util.Collections; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * The identity of a querying user, as seen by authorization plugins. + * Carries the user name, optional group names (resolved by the engine at + * authentication time) and an optional {@link Principal}. + */ +public final class UserIdentity { + + private final String user; + private final Set