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 @@ + + + + 4.0.0 + + + org.apache.drill + auth-parent + 1.23.0-SNAPSHOT + + + drill-ranger-plugin-shim + Drill : Ranger Drill Plugin Shim + + Thin shim between the Drillbit and the drill-ranger-plugin authorization + module. Lives on the Drillbit's main classpath and implements the + org.apache.drill.exec.security.spi.AccessAuthorizer SPI; delegates all + calls to DrillAccessControl (in drill-ranger-plugin) via reflection + through an isolated DrillRangerPluginClassLoader. + + Mirrors Presto's ranger-presto-plugin-shim in the Ranger repository: + - drill-ranger-plugin-shim : Drillbit main classpath (this module) + - drill-ranger-plugin : RangerPluginClassLoader isolated directory + (jars/ranger-drill-plugin-impl/) + + The shim deliberately has NO compile-time dependency on + drill-ranger-plugin: DrillAccessControl is referenced only via a string + constant and reflection, so the two jars are coupled solely at runtime + through the plugin classloader. + + The Drillbit discovers this module's RangerAccessAuthorizerFactory via + META-INF/services (ServiceLoader) registered in this jar. + + + + + + org.apache.drill + drill-security-spi + ${project.version} + provided + + + + org.apache.ranger + ranger-plugin-classloader + ${ranger.version} + provided + + + org.slf4j + slf4j-api + provided + + + junit + junit + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + diff --git a/auth/drill-ranger-plugin-shim/src/main/java/org/apache/drill/exec/security/ranger/DrillRangerPluginClassLoader.java b/auth/drill-ranger-plugin-shim/src/main/java/org/apache/drill/exec/security/ranger/DrillRangerPluginClassLoader.java new file mode 100644 index 00000000000..1221296cd73 --- /dev/null +++ b/auth/drill-ranger-plugin-shim/src/main/java/org/apache/drill/exec/security/ranger/DrillRangerPluginClassLoader.java @@ -0,0 +1,119 @@ +/* + * 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.ranger.plugin.classloader.RangerPluginClassLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; + +/** + * A {@link RangerPluginClassLoader} subclass that blocks the Jersey 3.1.9 + * MultiPart SPI from leaking into the Jersey 2.35 Ranger client via the + * parent (Drillbit) classpath. + */ +public final class DrillRangerPluginClassLoader extends RangerPluginClassLoader { + + private static final Logger logger = LoggerFactory.getLogger(DrillRangerPluginClassLoader.class); + + /** + * SPI resource name that Jersey's {@code ServiceFinder} scans to locate + * auto-discoverable providers. Jersey 2.35 and 3.1.9 share this file + * name (the SPI contract is package-private and unchanged across the + * two versions). + */ + private static final String AUTODISCOVERABLE_SPI = + "META-INF/services/org.glassfish.jersey.internal.spi.AutoDiscoverable"; + + /** + * FQN that exists only in Jersey 3.x. Its presence in an + * {@code AutoDiscoverable} SPI file is a reliable marker that the file + * comes from a Jersey 3.1.9 jar on the Drillbit classpath and must be + * hidden from the 2.35 {@code ServiceFinder}. + */ + private static final String JERSEY3_MULTIPART_MARKER = + "org.glassfish.jersey.media.multipart.MultiPartFeatureAutodiscoverable"; + + public DrillRangerPluginClassLoader(String pluginType, Class pluginClass) throws Exception { + super(pluginType, pluginClass); + logger.info("DrillRangerPluginClassLoader initialized for plugin type: {}", pluginType); + } + + /** + * Returns merged child+component resources, with the Jersey 3.1.9 + * MultiPart {@code AutoDiscoverable} SPI entry removed when present. + * + *

Non-SPI resources are returned unchanged so that Ranger's own + * resource lookups (configuration files, native libraries, etc.) are + * not affected.

+ */ + @Override + public Enumeration findResources(String name) { + // Base RangerPluginClassLoader.findResources does not declare IOException, + // so super.findResources cannot throw it either; no try/catch needed. + Enumeration merged = super.findResources(name); + if (!AUTODISCOVERABLE_SPI.equals(name)) { + return merged; + } + List kept = new ArrayList<>(); + while (merged.hasMoreElements()) { + URL url = merged.nextElement(); + if (!declaresJersey3Multipart(url)) { + kept.add(url); + } else { + logger.debug("Filtered Jersey 3.1.9 MultiPart AutoDiscoverable SPI entry: {}", url); + } + } + return Collections.enumeration(kept); + } + + /** + * Returns {@code true} if the given SPI resource URL declares the + * Jersey 3.x {@code MultiPartFeatureAutodiscoverable} FQN. + * + *

If 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, Set columns, AccessType accessType) { + activateClassLoader(); + try { + return delegate.checkColumnAccess(user, dataSource, schema, table, columns, accessType); + } catch (Exception e) { + logger.error("Failed to invoke DrillAccessControl.checkColumnAccess()", e); + return false; // fail-closed on error + } finally { + deactivateClassLoader(); + } + } + + private void activateClassLoader() { + if (pluginClassLoader != null) { + pluginClassLoader.activate(); + } + } + + private void deactivateClassLoader() { + if (pluginClassLoader != null) { + pluginClassLoader.deactivate(); + } + } + + /** + * Releases the delegate's plugin resources (policy-refresh threads, policy + * caches) through the same classloader-activated contract used by the + * access checks. Forwarded by {@code AccessAuthorizerManager} when the + * Drillbit shuts down. Idempotent; failures are logged, never thrown, so a + * failing authorizer cannot abort Drill shutdown. + */ + @Override + public void close() { + activateClassLoader(); + try { + delegate.close(); + } catch (Exception e) { + logger.warn("Failed to close DrillAccessControl", e); + } finally { + deactivateClassLoader(); + } + } + + /** + * Holder for the singleton {@link DrillRangerPluginClassLoader}. The + * base {@code RangerPluginClassLoader.getInstance()} cannot return our + * subclass, so Drill keeps its own single instance here. Initialized + * lazily on first class-loading of the enclosing authorizer. + */ + private static final class DrillRangerPluginClassLoaderHolder { + static final RangerPluginClassLoader INSTANCE; + + static { + try { + INSTANCE = new DrillRangerPluginClassLoader( + RANGER_PLUGIN_TYPE, RangerAccessAuthorizer.class); + } catch (Exception e) { + throw new ExceptionInInitializerError(e); + } + } + } +} diff --git a/auth/drill-ranger-plugin-shim/src/main/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizerFactory.java b/auth/drill-ranger-plugin-shim/src/main/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizerFactory.java new file mode 100644 index 00000000000..64503f9009c --- /dev/null +++ b/auth/drill-ranger-plugin-shim/src/main/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizerFactory.java @@ -0,0 +1,60 @@ +/* + * 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.AccessAuthorizerFactory; + +import java.util.Map; + +import static java.util.Objects.requireNonNull; + +/** + * {@link AccessAuthorizerFactory} for the Ranger-backed authorizer. + *

Registered 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): + * + *

    + *
  • {@code service.name} — Ranger service instance name + * (default {@code "drill"})
  • + *
+ * + *

{@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 config) { + requireNonNull(config, "config is null"); + String serviceName = config.getOrDefault(CONFIG_SERVICE_NAME, DEFAULT_SERVICE_NAME); + return new RangerAccessAuthorizer(serviceName); + } +} diff --git a/auth/drill-ranger-plugin-shim/src/main/resources/META-INF/services/org.apache.drill.exec.security.spi.AccessAuthorizerFactory b/auth/drill-ranger-plugin-shim/src/main/resources/META-INF/services/org.apache.drill.exec.security.spi.AccessAuthorizerFactory new file mode 100644 index 00000000000..625f2ee4c3e --- /dev/null +++ b/auth/drill-ranger-plugin-shim/src/main/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.ranger.RangerAccessAuthorizerFactory diff --git a/auth/drill-ranger-plugin-shim/src/test/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizerTest.java b/auth/drill-ranger-plugin-shim/src/test/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizerTest.java new file mode 100644 index 00000000000..b2e23112f6b --- /dev/null +++ b/auth/drill-ranger-plugin-shim/src/test/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizerTest.java @@ -0,0 +1,211 @@ +/* + * 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.AccessType; +import org.apache.drill.exec.security.spi.UserIdentity; +import org.apache.ranger.authorization.drill.authorizer.DrillAccessControl; +import org.apache.ranger.plugin.classloader.RangerPluginClassLoader; +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link RangerAccessAuthorizer}. + * + *

{@code RangerAccessAuthorizer} delegates to {@code DrillAccessControl} + * (in {@code drill-ranger-plugin}) through the {@link AccessAuthorizer} + * SPI interface. These tests verify the delegation by:

+ *
    + *
  1. Injecting a mock {@link RangerPluginClassLoader} via the package-private + * constructor {@link RangerAccessAuthorizer#RangerAccessAuthorizer(RangerPluginClassLoader, String)}. + * This is necessary because Mockito refuses to mock static methods of + * {@link ClassLoader} subclasses (to avoid class-loading infinite loops), + * so the production classloader holder cannot be stubbed. The mock + * classloader's {@code loadClass(String)} delegates to the test + * classloader, so the reflective class lookup resolves to the test stub + * class that lives in the test source tree (same FQCN as the real + * plugin class).
  2. + *
  3. Asserting against the stub's captured arguments and control knobs + * (see the stub {@link DrillAccessControl} for the available knobs). + * This exercises the real production path: reflective + * {@code getConstructor(String).newInstance(...)} + cast to + * {@link AccessAuthorizer} + direct virtual calls.
  4. + *
+ * + *

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"); + + Set columns = new HashSet<>(Arrays.asList("id", "amount")); + assertFalse(authorizer.checkColumnAccess( + UserIdentity.of(USER), DS, SCHEMA, TABLE, columns, AccessType.SELECT)); + + assertEquals(USER, DrillAccessControl.lastUser.getUser()); + assertEquals(DS, DrillAccessControl.lastDataSource); + assertEquals(SCHEMA, DrillAccessControl.lastSchema); + assertEquals(TABLE, DrillAccessControl.lastTable); + assertEquals(columns, DrillAccessControl.lastColumns); + assertEquals(AccessType.SELECT, DrillAccessControl.lastAccessType); + } + + @Test + public void checkColumnAccess_returnsFalse_whenInvocationThrows() { + RangerPluginClassLoader mockCl = mockPluginClassLoader(); + DrillAccessControl.checkFailure = new RuntimeException("column check boom"); + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(mockCl, "mySvc"); + + Set columns = new HashSet<>(Arrays.asList("id")); + // fail-closed on error + assertFalse(authorizer.checkColumnAccess( + UserIdentity.of(USER), DS, SCHEMA, TABLE, columns, AccessType.SELECT)); + } + + // ======================================================================== + // Shutdown lifecycle + // ======================================================================== + + @Test + public void close_delegatesToDrillAccessControl() { + RangerPluginClassLoader mockCl = mockPluginClassLoader(); + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(mockCl, "mySvc"); + + authorizer.close(); + + assertEquals("close() must be forwarded to the delegate", 1, + DrillAccessControl.closeCount); + } +} diff --git a/auth/drill-ranger-plugin-shim/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControl.java b/auth/drill-ranger-plugin-shim/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControl.java new file mode 100644 index 00000000000..7c9cc99ddac --- /dev/null +++ b/auth/drill-ranger-plugin-shim/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControl.java @@ -0,0 +1,116 @@ +/* + * 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 java.util.Set; + +/** + * Test stub for the real {@code DrillAccessControl} class that lives in the + * {@code drill-ranger-plugin} module (loaded by the isolated + * {@code RangerPluginClassLoader} at runtime). + * + *

Mirrors 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 Set lastColumns; + + public DrillAccessControl(String serviceName) { + lastServiceName = serviceName; + if (constructFails) { + throw new RuntimeException("construct boom"); + } + } + + @Override + public boolean checkTableAccess(UserIdentity user, String dataSource, String schema, + String table, AccessType accessType) { + lastUser = user; + lastDataSource = dataSource; + lastSchema = schema; + lastTable = table; + lastAccessType = accessType; + if (checkFailure != null) { + throw checkFailure; + } + return result; + } + + @Override + public boolean checkColumnAccess(UserIdentity user, String dataSource, String schema, + String table, Set columns, AccessType accessType) { + lastUser = user; + lastDataSource = dataSource; + lastSchema = schema; + lastTable = table; + lastColumns = columns; + lastAccessType = accessType; + if (checkFailure != null) { + throw checkFailure; + } + return result; + } + + @Override + public void close() { + closeCount++; + } + + /** Resets all control knobs and captured arguments. */ + public static void reset() { + lastServiceName = null; + constructFails = false; + result = true; + checkFailure = null; + closeCount = 0; + lastUser = null; + lastDataSource = null; + lastSchema = null; + lastTable = null; + lastAccessType = null; + lastColumns = null; + } +} diff --git a/auth/drill-ranger-plugin/pom.xml b/auth/drill-ranger-plugin/pom.xml new file mode 100644 index 00000000000..0d6c047811f --- /dev/null +++ b/auth/drill-ranger-plugin/pom.xml @@ -0,0 +1,298 @@ + + + + 4.0.0 + + + org.apache.drill + auth-parent + 1.23.0-SNAPSHOT + + + drill-ranger-plugin + Drill : Ranger Drill Authorization Plugin + + Apache Ranger authorization plugin for Apache Drill. + Loaded by the Drillbit at runtime; performs local in-memory policy + evaluation against policies pulled from Ranger Admin. + + + + + org.apache.drill + drill-security-spi + ${project.version} + provided + + + org.apache.ranger + ranger-plugins-common + ${ranger.version} + + + org.apache.ranger + ranger-plugins-cred + + + io.netty + * + + + org.slf4j + * + + + ch.qos.logback + * + + + org.apache.logging.log4j + * + + + log4j + log4j + + + commons-logging + commons-logging + + + org.apache.hadoop + * + + + com.fasterxml.jackson.core + * + + + com.fasterxml.jackson.dataformat + * + + + com.fasterxml.jackson.datatype + * + + + javax.servlet + * + + + + com.sun.jersey + jersey-bundle + + + com.sun.jersey + jersey-json + + + org.ow2.asm + * + + + + + + org.apache.ranger + ranger-audit-dest-log4j + ${ranger.version} + + + org.slf4j + * + + + + + org.apache.ranger + ranger-knox-plugin + ${ranger.version} + + + org.apache.knox + * + + + org.apache.ranger + ranger-knox-plugin-shim + + + org.apache.ranger + ranger-audit-dest-hdfs + + + org.apache.ranger + ranger-audit-dest-solr + + + org.apache.ranger + ranger-plugins-common + + + com.google.protobuf + protobuf-java + + + commons-collections + commons-collections + + + javax.servlet + * + + + org.apache.hadoop + hadoop-client-api + + + org.apache.hadoop + hadoop-client-runtime + + + org.apache.httpcomponents + httpcore + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-core + + + com.sun.jersey + * + + + javax.ws.rs + jsr311-api + + + + + + + org.glassfish.jersey.core + jersey-client + ${jersey.ranger.version} + + + org.glassfish.jersey.core + jersey-common + ${jersey.ranger.version} + + + org.glassfish.jersey.inject + jersey-hk2 + ${jersey.ranger.version} + + + jakarta.ws.rs + jakarta.ws.rs-api + ${jaxrs.api.version} + + + io.netty + netty-handler + provided + + + io.netty + netty-common + provided + + + org.apache.hadoop + hadoop-common + provided + + + commons-codec + commons-codec + + + org.slf4j + slf4j-reload4j + + + javax.servlet + javax.servlet-api + + + javax.servlet.jsp + jsp-api + + + + + org.slf4j + slf4j-api + provided + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + diff --git a/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControl.java b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControl.java new file mode 100644 index 00000000000..fa496dc70fe --- /dev/null +++ b/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. + * + *

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.

+ * + *

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 Set 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 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). + * + *

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 (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, Set 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 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. + * + *

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. + * + *

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, Set columns, DrillAccessType operator) { + if (!validate(user, dataSource, schema, table) || !validColumns(columns)) { + logger.warn("Column access check denied: invalid arguments for user={}, datasource={}, schema={}, table={}", + user == null ? null : user.getUser(), dataSource, schema, table); + return false; + } + Optional schemaOpt = Optional.ofNullable(schema); + Optional tableOpt = Optional.ofNullable(table); + + for (String column : columns) { + DrillAccessResource resource = new DrillAccessResource(dataSource, + schemaOpt, tableOpt, Optional.of(column)); + + // Column-level check uses SELF for exact column matching: only policies + // whose column resource matches the requested column will be applied. + boolean allowed = checkAccess(user, resource, operator, + RangerAccessRequest.ResourceMatchingScope.SELF); + if (logger.isDebugEnabled()) { + logger.debug("checkColumnAccess result for user={}, datasource={}, schema={}, table={}, " + + "column={}, operator={}: result={}", + user.getUser(), dataSource, schema, table, column, operator.name(), allowed); + } + if (!allowed) { + // Fail fast on first denied column — no need to check the rest. + logger.warn("Column access denied for user={}, column={}.{}.{}", + user.getUser(), dataSource, schema, table, column); + return false; + } + } + return true; + } + + /** + * Releases the Ranger plugin resources owned by the singleton + * {@link RangerBaseAuthorizer} (policy-refresh threads, policy-engine + * caches). Idempotent; safe when the plugin was never initialized. Called + * through {@link DrillAccessControl#close()} when the Drillbit shuts down. + */ + public void close() { + authorizer.cleanUp(); + } + + /** + * Builds a {@link DrillRangerAccessRequest} from the identity, resource and + * access type, then evaluates it against the Ranger policy engine. + */ + private boolean checkAccess(UserIdentity user, DrillAccessResource drillAccessResource, + DrillAccessType operator, RangerAccessRequest.ResourceMatchingScope scope) { + DrillRangerAccessRequest request = DrillRangerAccessRequest.builder() + .user(user.getUser()) + .groups(user.getGroups()) + .resource(drillAccessResource) + .accessType(operator) + .resourceMatchingScope(scope) + .build(); + + return authorizer.isAccessAllowed(request.toRangerRequest()); + } + + /** + * Validates the arguments shared by table- and column-level checks: the + * identity, its user name, dataSource, schema and table must all be + * non-null and non-empty (fail-closed on malformed input). + */ + private boolean validate(UserIdentity user, String dataSource, String schema, String table) { + return user != null + && user.getUser() != null && !user.getUser().trim().isEmpty() + && dataSource != null && !dataSource.trim().isEmpty() + && schema != null && !schema.trim().isEmpty() + && table != null && !table.trim().isEmpty(); + } + + /** + * Validates the column set: non-null, non-empty, and every column name + * non-null and non-empty. + */ + private boolean validColumns(Set columns) { + return columns != null && !columns.isEmpty() + && columns.stream().allMatch(c -> c != null && !c.trim().isEmpty()); + } +} diff --git a/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerBaseAuthorizer.java b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerBaseAuthorizer.java new file mode 100644 index 00000000000..df25afe57ba --- /dev/null +++ b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerBaseAuthorizer.java @@ -0,0 +1,118 @@ +/* + * 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.audit.RangerDefaultAuditHandler; +import org.apache.ranger.plugin.policyengine.RangerAccessRequest; +import org.apache.ranger.plugin.policyengine.RangerAccessResult; +import org.apache.ranger.plugin.service.RangerBasePlugin; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Singleton wrapper around {@link RangerBasePlugin} for the Drill service type. + * + *

Initialized 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(Map> resource) { + super(); + for (Map.Entry> entry : resource.entrySet()) { + String key = entry.getKey().toString(); + Optional value = entry.getValue(); + value.ifPresent(s -> this.setValue(key, s)); + if (logger.isDebugEnabled()) { + logger.debug("AccessResource set value: {} = {}", key, value); + } + } + } + + public DrillAccessResource(String dataSource, Optional schema, Optional table) { + setValue(RangerDrillResource.DATASOURCE.toString(), dataSource); + schema.ifPresent(s -> setValue(RangerDrillResource.SCHEMA.toString(), s)); + table.ifPresent(s -> setValue(RangerDrillResource.TABLE.toString(), s)); + } + + public DrillAccessResource(String dataSource, Optional schema, Optional table, + Optional column) { + setValue(RangerDrillResource.DATASOURCE.toString(), dataSource); + schema.ifPresent(s -> setValue(RangerDrillResource.SCHEMA.toString(), s)); + table.ifPresent(s -> setValue(RangerDrillResource.TABLE.toString(), s)); + column.ifPresent(s -> setValue(RangerDrillResource.COLUMN.toString(), s)); + } + + public String getDataSource() { + return (String) getValue(RangerDrillResource.DATASOURCE.toString()); + } + + public String getTable() { + return (String) getValue(RangerDrillResource.TABLE.toString()); + } + + public String getSchema() { + return (String) getValue(RangerDrillResource.SCHEMA.toString()); + } + + +} + +enum RangerDrillResource { + DATASOURCE("datasource"), + SCHEMA("schema"), + TABLE("table"), + COLUMN("column"); + + private final String key; + + RangerDrillResource(String key) { + this.key = key; + } + + @Override + public String toString() { + return key; + } +} diff --git a/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessType.java b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessType.java new file mode 100644 index 00000000000..b98e147fe4c --- /dev/null +++ b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessType.java @@ -0,0 +1,21 @@ +/* + * 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; + +public enum DrillAccessType { + CREATE, DROP, SELECT; +} diff --git a/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequest.java b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequest.java new file mode 100644 index 00000000000..4381f475860 --- /dev/null +++ b/auth/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequest.java @@ -0,0 +1,145 @@ +/* + * 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.RangerAccessRequest; +import org.apache.ranger.plugin.policyengine.RangerAccessRequestImpl; + +import java.util.HashSet; +import java.util.Set; + + +public class DrillRangerAccessRequest { + + private String user; + private Set groups = new HashSet<>(); + private DrillAccessResource resource; + private DrillAccessType accessType; + private String action; + private String clientIPAddress; + private String clientType; + // Resource matching scope. Table-level checks use SELF_OR_DESCENDANTS so a + // table request can match column-level policies (column is a descendant of + // table in the resource hierarchy). Column-level checks use SELF for exact + // column matching. Defaults to SELF_OR_DESCENDANTS to preserve historical + // behavior when the caller does not specify a scope. + private RangerAccessRequest.ResourceMatchingScope resourceMatchingScope = + RangerAccessRequest.ResourceMatchingScope.SELF_OR_DESCENDANTS; + + private DrillRangerAccessRequest(Builder builder) { + this.user = builder.user; + this.groups = builder.groups; + this.resource = builder.resource; + this.accessType = builder.accessType; + this.action = builder.action; + this.clientIPAddress = builder.clientIPAddress; + this.clientType = builder.clientType; + this.resourceMatchingScope = builder.resourceMatchingScope; + } + + public RangerAccessRequest toRangerRequest() { + RangerAccessRequestImpl request = new RangerAccessRequestImpl(); + request.setUser(user); + request.setUserGroups(groups); + request.setResource(resource); + // Access type name MUST match the service-def's accessTypes[].name exactly + // (Ranger matching is case-sensitive). DrillAccessType enum constants are + // uppercase (SELECT, CREATE, ...) and the service-def registers them as + // uppercase too, so we use the enum name directly — no toLowerCase(). + request.setAccessType(accessType.name()); + request.setAction(action != null ? action : accessType.name()); + request.setClientIPAddress(clientIPAddress); + request.setClientType(clientType); + request.setResourceMatchingScope(resourceMatchingScope); + + return request; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String user; + private Set groups = new HashSet<>(); + private DrillAccessResource resource; + private DrillAccessType accessType; + private String action; + private String clientIPAddress; + private String clientType; + private RangerAccessRequest.ResourceMatchingScope resourceMatchingScope = + RangerAccessRequest.ResourceMatchingScope.SELF_OR_DESCENDANTS; + + public Builder user(String user) { + this.user = user; + return this; + } + + public Builder groups(Set groups) { + this.groups = groups != null ? new HashSet<>(groups) : new HashSet<>(); + return this; + } + + public Builder addGroup(String group) { + this.groups.add(group); + return this; + } + + public Builder resource(DrillAccessResource resource) { + this.resource = resource; + return this; + } + + public Builder accessType(DrillAccessType accessType) { + this.accessType = accessType; + return this; + } + + public Builder action(String action) { + this.action = action; + return this; + } + + public Builder clientIPAddress(String clientIPAddress) { + this.clientIPAddress = clientIPAddress; + return this; + } + + public Builder clientType(String clientType) { + this.clientType = clientType; + return this; + } + + /** + * Sets the resource matching scope. Use {@code SELF_OR_DESCENDANTS} for + * table-level checks (so a table request can match column-level policies + * whose resource is a descendant of table), and {@code SELF} for exact + * column-level matching. + * + * @param scope the resource matching scope + * @return this builder + */ + public Builder resourceMatchingScope(RangerAccessRequest.ResourceMatchingScope scope) { + this.resourceMatchingScope = scope; + return this; + } + + public DrillRangerAccessRequest build() { + return new DrillRangerAccessRequest(this); + } + } +} diff --git a/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControlTest.java b/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControlTest.java new file mode 100644 index 00000000000..86407c7fb46 --- /dev/null +++ b/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControlTest.java @@ -0,0 +1,318 @@ +/* + * 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.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.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the instance-based {@link DrillAccessControl} SPI + * implementation. Covers system-schema bypass, fail-closed behavior + * (exception / malformed schema), SPI-enum-to-DrillAccessType mapping, + * delegation to {@link DrillAuthorizer} and user-group resolution + * (engine-supplied groups take precedence, UGI is the fallback). + */ +public class DrillAccessControlTest { + + private static final String USER = "root"; + private static final String DS = "mysql"; + private static final String SCHEMA = "shf"; + private static final String TABLE = "orders"; + + /** + * Class-level mock of {@link UserGroupInformation} to prevent JNI-based group + * lookup ({@code JniBasedUnixGroupsMapping}) which fails on Windows / + * non-Unix environments and pollutes test logs with IOException stacks. + * Opened in {@link #setUp()} and closed in {@link #tearDown()} so that every + * test method — including those that indirectly call {@code getUserGroups} + * via the group-resolution fallback — gets a deterministic empty group set + * without touching the OS. + */ + private MockedStatic ugiMock; + private UserGroupInformation mockUgi; + + private DrillAuthorizer mockAuthorizer; + private DrillAccessControl accessControl; + + @BeforeEach + public void setUp() { + // Stub UGI for any user: createRemoteUser returns a mock whose + // getGroupNames() returns an empty array by default. Individual tests + // (e.g. getUserGroups_returnsNonNullForValidUser) can re-stub mockUgi + // to return specific groups or throw exceptions. + ugiMock = mockStatic(UserGroupInformation.class); + mockUgi = mock(UserGroupInformation.class); + ugiMock.when(() -> UserGroupInformation.createRemoteUser(anyString())) + .thenReturn(mockUgi); + when(mockUgi.getGroupNames()).thenReturn(new String[0]); + + mockAuthorizer = mock(DrillAuthorizer.class); + accessControl = new DrillAccessControl(mockAuthorizer); + } + + @AfterEach + public void tearDown() { + if (ugiMock != null) { + ugiMock.close(); + ugiMock = null; + } + } + + // ======================================================================== + // System-schema bypass + // ======================================================================== + + @Test + public void checkTableAccess_bypassesSystemSchema_informationSchema() { + assertTrue(accessControl.checkTableAccess( + UserIdentity.of(USER), "dfs", "INFORMATION_SCHEMA", "TABLES", AccessType.SELECT)); + verify(mockAuthorizer, never()).checkTableAccess(any(), any(), any(), any(), any()); + } + + @Test + public void checkTableAccess_bypassesSystemSchema_sys_caseInsensitive() { + for (String schema : new String[] {"sys", "Sys", "SYS"}) { + assertTrue(accessControl.checkTableAccess( + UserIdentity.of(USER), "dfs", schema, "DRILLBITS", AccessType.SELECT), + "schema=" + schema + " should bypass authorization"); + } + verify(mockAuthorizer, never()).checkTableAccess(any(), any(), any(), any(), any()); + } + + @Test + public void checkTableAccess_bypassesSystemSchema_compoundPath() { + // Top-level segment "information_schema" should match, even with compound path + assertTrue(accessControl.checkTableAccess( + UserIdentity.of(USER), "dfs", "information_schema.tables", "COLUMNS", AccessType.SELECT)); + verify(mockAuthorizer, never()).checkTableAccess(any(), any(), any(), any(), any()); + } + + @Test + public void checkColumnAccess_bypassesSystemSchema() { + Set columns = new HashSet<>(Collections.singletonList("TABLE_NAME")); + assertTrue(accessControl.checkColumnAccess( + UserIdentity.of(USER), "dfs", "INFORMATION_SCHEMA", "TABLES", columns, AccessType.SELECT)); + verify(mockAuthorizer, never()).checkColumnAccess(any(), any(), any(), any(), any(), any()); + } + + // ======================================================================== + // Malformed schema and unknown access type: fail-closed + // ======================================================================== + + @Test + public void checkTableAccess_doesNotBypass_nullSchema_denied() { + // A null schema is not a system schema; it must fail closed (denied) rather + // than being silently treated as one and bypassing authorization. + assertFalse(accessControl.checkTableAccess( + UserIdentity.of(USER), "dfs", null, TABLE, AccessType.SELECT), + "null schema must not bypass authorization"); + verify(mockAuthorizer, never()).checkTableAccess(any(), any(), any(), any(), any()); + } + + @Test + public void checkTableAccess_doesNotBypass_emptySchema_denied() { + // Empty/whitespace schemas are not system schemas; they must fail closed + // (denied) rather than silently bypassing authorization. + for (String schema : new String[] {"", " "}) { + assertFalse(accessControl.checkTableAccess( + UserIdentity.of(USER), "dfs", schema, TABLE, AccessType.SELECT), + "empty/whitespace schema must not bypass authorization, schema='" + schema + "'"); + } + verify(mockAuthorizer, never()).checkTableAccess(any(), any(), any(), any(), any()); + } + + // ======================================================================== + // Delegation to DrillAuthorizer + // ======================================================================== + + @Test + public void checkTableAccess_delegatesToAuthorizer() { + when(mockAuthorizer.checkTableAccess(any(), anyString(), anyString(), anyString(), + eq(DrillAccessType.SELECT))).thenReturn(true); + + assertTrue(accessControl.checkTableAccess( + UserIdentity.of(USER), DS, SCHEMA, TABLE, AccessType.SELECT)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserIdentity.class); + verify(mockAuthorizer).checkTableAccess(captor.capture(), eq(DS), eq(SCHEMA), + eq(TABLE), eq(DrillAccessType.SELECT)); + assertEquals(USER, captor.getValue().getUser()); + } + + @Test + public void checkColumnAccess_delegatesToAuthorizer() { + when(mockAuthorizer.checkColumnAccess(any(), anyString(), anyString(), anyString(), + any(), eq(DrillAccessType.SELECT))).thenReturn(false); + + Set columns = new HashSet<>(Arrays.asList("id", "amount")); + assertFalse(accessControl.checkColumnAccess( + UserIdentity.of(USER), DS, SCHEMA, TABLE, columns, AccessType.SELECT)); + + ArgumentCaptor identityCaptor = ArgumentCaptor.forClass(UserIdentity.class); + ArgumentCaptor> columnsCaptor = ArgumentCaptor.forClass(Set.class); + verify(mockAuthorizer).checkColumnAccess(identityCaptor.capture(), eq(DS), eq(SCHEMA), + eq(TABLE), columnsCaptor.capture(), eq(DrillAccessType.SELECT)); + assertEquals(USER, identityCaptor.getValue().getUser()); + assertEquals(columns, columnsCaptor.getValue()); + } + + @Test + public void checkTableAccess_mapsSpiEnumToDrillAccessType_byName() { + // Pins the name-based mapping: every SPI AccessType the engine can emit + // must have a DrillAccessType with the same name, otherwise the check + // fails closed at run time. + when(mockAuthorizer.checkTableAccess(any(), anyString(), anyString(), anyString(), any())) + .thenReturn(true); + + for (AccessType type : AccessType.values()) { + assertTrue(accessControl.checkTableAccess( + UserIdentity.of(USER), DS, SCHEMA, TABLE, type), + "SPI AccessType." + type.name() + " must map to a DrillAccessType"); + verify(mockAuthorizer).checkTableAccess(any(), eq(DS), eq(SCHEMA), eq(TABLE), + eq(DrillAccessType.valueOf(type.name()))); + } + } + + @Test + public void checkTableAccess_returnsFalse_whenAuthorizerThrows() { + when(mockAuthorizer.checkTableAccess(any(), anyString(), anyString(), anyString(), any())) + .thenThrow(new RuntimeException("boom")); + + assertFalse(accessControl.checkTableAccess( + UserIdentity.of(USER), DS, SCHEMA, TABLE, AccessType.SELECT)); + } + + @Test + public void checkColumnAccess_returnsFalse_whenAuthorizerThrows() { + when(mockAuthorizer.checkColumnAccess(any(), anyString(), anyString(), anyString(), any(), any())) + .thenThrow(new RuntimeException("boom")); + + Set columns = new HashSet<>(Collections.singletonList("amount")); + assertFalse(accessControl.checkColumnAccess( + UserIdentity.of(USER), DS, SCHEMA, TABLE, columns, AccessType.SELECT)); + } + + // ======================================================================== + // Group resolution: engine-supplied groups win, UGI is the fallback + // ======================================================================== + + @Test + public void checkTableAccess_usesEngineSuppliedGroups() { + Set engineGroups = new HashSet<>(Arrays.asList("analysts", "etl")); + UserIdentity identity = UserIdentity.builder() + .setUser(USER) + .setGroups(engineGroups) + .build(); + + accessControl.checkTableAccess(identity, DS, SCHEMA, TABLE, AccessType.SELECT); + + // The identity is passed through as-is (engine groups already present) + ArgumentCaptor captor = ArgumentCaptor.forClass(UserIdentity.class); + verify(mockAuthorizer).checkTableAccess(captor.capture(), eq(DS), eq(SCHEMA), + eq(TABLE), eq(DrillAccessType.SELECT)); + assertEquals(engineGroups, captor.getValue().getGroups()); + } + + @Test + public void checkTableAccess_fallsBackToUgiGroups_whenIdentityHasNone() { + // UserIdentity.of() carries no groups → resolveIdentity falls back to UGI. + // Re-stub mockUgi to return specific groups and verify they reach the authorizer. + when(mockUgi.getGroupNames()).thenReturn(new String[] {"root", "wheel"}); + + accessControl.checkTableAccess(UserIdentity.of(USER), DS, SCHEMA, TABLE, AccessType.SELECT); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserIdentity.class); + verify(mockAuthorizer).checkTableAccess(captor.capture(), eq(DS), eq(SCHEMA), + eq(TABLE), eq(DrillAccessType.SELECT)); + assertEquals(new HashSet<>(Arrays.asList("root", "wheel")), captor.getValue().getGroups()); + } + + // ======================================================================== + // getUserGroups (UGI resolution helper) + // ======================================================================== + + @Test + public void getUserGroups_returnsEmptyForNullUser() { + assertEquals(Collections.emptySet(), DrillAccessControl.getUserGroups(null)); + } + + @Test + public void getUserGroups_returnsEmptyForEmptyUser() { + assertEquals(Collections.emptySet(), DrillAccessControl.getUserGroups("")); + assertEquals(Collections.emptySet(), DrillAccessControl.getUserGroups(" ")); + } + + @Test + public void getUserGroups_returnsNonNullForValidUser() { + // Re-stub the class-level mockUgi to return specific groups, then verify + // getUserGroups propagates them correctly. + when(mockUgi.getGroupNames()).thenReturn(new String[] {"root", "wheel"}); + + Set groups = DrillAccessControl.getUserGroups(USER); + assertNotNull(groups); + assertEquals(new HashSet<>(Arrays.asList("root", "wheel")), groups); + } + + @Test + public void getUserGroups_returnsEmptySet_whenUgiThrows() { + // Verifies the catch-block fallback: when UGI lookup throws, the method + // returns an empty set instead of propagating the exception. + when(mockUgi.getGroupNames()).thenThrow(new RuntimeException("ugi boom")); + + Set groups = DrillAccessControl.getUserGroups(USER); + assertNotNull(groups); + assertTrue(groups.isEmpty()); + } + + // ======================================================================== + // Shutdown lifecycle + // ======================================================================== + + @Test + public void close_releasesDelegateAuthorizer() { + DrillAccessControl accessControl = new DrillAccessControl(mockAuthorizer); + + accessControl.close(); + + verify(mockAuthorizer).close(); + } +} diff --git a/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizerTest.java b/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizerTest.java new file mode 100644 index 00000000000..9f5040fea9a --- /dev/null +++ b/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizerTest.java @@ -0,0 +1,196 @@ +/* + * 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.DrillAccessType; +import org.apache.ranger.plugin.policyengine.RangerAccessRequest; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link DrillAuthorizer}. + * Covers argument validation (fail-closed), request building (user, groups, + * resource values, matching scope), and per-column fail-fast behavior. + */ +public class DrillAuthorizerTest { + + private static final String USER = "root"; + private static final String DS = "mysql"; + private static final String SCHEMA = "shf"; + private static final String TABLE = "orders"; + private static final Set GROUPS = + Collections.singleton("analysts"); + + /** + * Creates a DrillAuthorizer with the singleton {@link RangerBaseAuthorizer} + * mocked out, so the constructor does not actually contact Ranger Admin. + */ + private DrillAuthorizer newAuthorizer(RangerBaseAuthorizer mockBase) { + try (MockedStatic mocked = mockStatic(RangerBaseAuthorizer.class)) { + mocked.when(RangerBaseAuthorizer::getInstance).thenReturn(mockBase); + return new DrillAuthorizer("svc"); + } + } + + private UserIdentity identity() { + return UserIdentity.builder().setUser(USER).setGroups(GROUPS).build(); + } + + // ======================================================================== + // Argument validation: fail-closed, no Ranger call + // ======================================================================== + + @Test + public void checkTableAccess_returnsFalse_whenUserNull() { + RangerBaseAuthorizer mockBase = mock(RangerBaseAuthorizer.class); + DrillAuthorizer authorizer = newAuthorizer(mockBase); + assertFalse(authorizer.checkTableAccess(null, DS, SCHEMA, TABLE, DrillAccessType.SELECT)); + verify(mockBase, never()).isAccessAllowed(any()); + } + + @Test + public void checkTableAccess_returnsFalse_whenArgumentsEmpty() { + RangerBaseAuthorizer mockBase = mock(RangerBaseAuthorizer.class); + DrillAuthorizer authorizer = newAuthorizer(mockBase); + UserIdentity user = identity(); + + assertFalse(authorizer.checkTableAccess(user, null, SCHEMA, TABLE, DrillAccessType.SELECT)); + assertFalse(authorizer.checkTableAccess(user, "", SCHEMA, TABLE, DrillAccessType.SELECT)); + assertFalse(authorizer.checkTableAccess(user, DS, null, TABLE, DrillAccessType.SELECT)); + assertFalse(authorizer.checkTableAccess(user, DS, " ", TABLE, DrillAccessType.SELECT)); + assertFalse(authorizer.checkTableAccess(user, DS, SCHEMA, null, DrillAccessType.SELECT)); + assertFalse(authorizer.checkTableAccess(user, DS, SCHEMA, "", DrillAccessType.SELECT)); + verify(mockBase, never()).isAccessAllowed(any()); + } + + @Test + public void checkColumnAccess_returnsFalse_whenColumnsInvalid() { + RangerBaseAuthorizer mockBase = mock(RangerBaseAuthorizer.class); + DrillAuthorizer authorizer = newAuthorizer(mockBase); + UserIdentity user = identity(); + + // null / empty / blank-element column sets all fail closed + assertFalse(authorizer.checkColumnAccess(user, DS, SCHEMA, TABLE, null, DrillAccessType.SELECT)); + assertFalse(authorizer.checkColumnAccess(user, DS, SCHEMA, TABLE, + Collections.emptySet(), DrillAccessType.SELECT)); + assertFalse(authorizer.checkColumnAccess(user, DS, SCHEMA, TABLE, + Collections.singleton(""), DrillAccessType.SELECT)); + assertFalse(authorizer.checkColumnAccess(user, DS, SCHEMA, TABLE, + Collections.singleton(" "), DrillAccessType.SELECT)); + verify(mockBase, never()).isAccessAllowed(any()); + } + + // ======================================================================== + // Request building: delegation with correct scope and values + // ======================================================================== + + @Test + public void checkTableAccess_buildsRequest_withSelfOrDescendantsScope() { + RangerBaseAuthorizer mockBase = mock(RangerBaseAuthorizer.class); + when(mockBase.isAccessAllowed(any())).thenReturn(true); + DrillAuthorizer authorizer = newAuthorizer(mockBase); + + assertTrue(authorizer.checkTableAccess(identity(), DS, SCHEMA, TABLE, DrillAccessType.SELECT)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RangerAccessRequest.class); + verify(mockBase).isAccessAllowed(captor.capture()); + RangerAccessRequest captured = captor.getValue(); + assertEquals(RangerAccessRequest.ResourceMatchingScope.SELF_OR_DESCENDANTS, + captured.getResourceMatchingScope()); + assertEquals(USER, captured.getUser()); + assertEquals(GROUPS, captured.getUserGroups()); + assertEquals("SELECT", captured.getAccessType()); + } + + @Test + public void checkColumnAccess_buildsRequest_withSelfScope() { + RangerBaseAuthorizer mockBase = mock(RangerBaseAuthorizer.class); + when(mockBase.isAccessAllowed(any())).thenReturn(true); + DrillAuthorizer authorizer = newAuthorizer(mockBase); + + assertTrue(authorizer.checkColumnAccess(identity(), DS, SCHEMA, TABLE, + Collections.singleton("amount"), DrillAccessType.SELECT)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RangerAccessRequest.class); + verify(mockBase).isAccessAllowed(captor.capture()); + assertEquals(RangerAccessRequest.ResourceMatchingScope.SELF, + captor.getValue().getResourceMatchingScope()); + } + + // ======================================================================== + // Per-column iteration: fail-fast + // ======================================================================== + + @Test + public void checkColumnAccess_checksEachColumnIndividually_failFast() { + RangerBaseAuthorizer mockBase = mock(RangerBaseAuthorizer.class); + // First column allowed, second column denied — fail fast on second + when(mockBase.isAccessAllowed(any())).thenReturn(true, false); + DrillAuthorizer authorizer = newAuthorizer(mockBase); + + // LinkedHashSet for deterministic iteration order + Set columns = new LinkedHashSet<>(Arrays.asList("c1", "c2", "c3")); + assertFalse(authorizer.checkColumnAccess(identity(), DS, SCHEMA, TABLE, columns, + DrillAccessType.SELECT)); + // c1 (true) + c2 (false) -> 2 invocations; c3 should NOT be reached + verify(mockBase, times(2)).isAccessAllowed(any()); + } + + @Test + public void checkColumnAccess_allColumnsAllowed_returnsTrue() { + RangerBaseAuthorizer mockBase = mock(RangerBaseAuthorizer.class); + when(mockBase.isAccessAllowed(any())).thenReturn(true); + DrillAuthorizer authorizer = newAuthorizer(mockBase); + + Set columns = new LinkedHashSet<>(Arrays.asList("c1", "c2", "c3")); + assertTrue(authorizer.checkColumnAccess(identity(), DS, SCHEMA, TABLE, columns, + DrillAccessType.SELECT)); + verify(mockBase, times(3)).isAccessAllowed(any()); + } + + // ======================================================================== + // Shutdown lifecycle + // ======================================================================== + + @Test + public void close_invokesBaseAuthorizerCleanUp() { + RangerBaseAuthorizer mockBase = mock(RangerBaseAuthorizer.class); + DrillAuthorizer authorizer = newAuthorizer(mockBase); + + authorizer.close(); + + verify(mockBase).cleanUp(); + } +} diff --git a/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/RangerBaseAuthorizerTest.java b/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/RangerBaseAuthorizerTest.java new file mode 100644 index 00000000000..c75809ed0f9 --- /dev/null +++ b/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/RangerBaseAuthorizerTest.java @@ -0,0 +1,134 @@ +/* + * 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.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for the lifecycle of {@link RangerBaseAuthorizer}, the singleton + * wrapper around {@link RangerDrillPlugin}. + * + *

The 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 (MockedConstruction mocked = + mockConstruction(RangerDrillPlugin.class)) { + authorizer.init("svc-after-cleanup"); + + RangerDrillPlugin constructed = mocked.constructed().get(0); + assertNotNull(getPluginField()); + verify(constructed).init(); + } catch (Exception e) { + throw new AssertionError("init() must succeed after cleanUp()", e); + } + } + + /** + * While a plugin is still present (not yet cleaned up), a second init() + * must early-return and NOT construct another plugin. + */ + @Test + public void init_withActivePlugin_isNoOp() { + try (MockedConstruction mocked = + mockConstruction(RangerDrillPlugin.class)) { + authorizer.init("svc-1"); + authorizer.init("svc-2"); + + // Only one plugin constructed; the second init() early-returned. + assertEquals(1, mocked.constructed().size()); + } catch (Exception e) { + throw new AssertionError("init() must not fail", e); + } + } +} \ No newline at end of file diff --git a/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/resource/DrillAccessResourceTest.java b/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/resource/DrillAccessResourceTest.java new file mode 100644 index 00000000000..73b7228f1f0 --- /dev/null +++ b/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/resource/DrillAccessResourceTest.java @@ -0,0 +1,104 @@ +/* + * 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.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Unit tests for {@link DrillAccessResource} construction and getters. + * Verifies that the resource keys are lowercase and that empty + * {@code Optional} values do not register a key. + */ +public class DrillAccessResourceTest { + + @Test + public void constructor_threeArgs_setsDatasourceSchemaTable() { + DrillAccessResource r = new DrillAccessResource( + "mysql", Optional.of("shf"), Optional.of("orders")); + assertEquals("mysql", r.getDataSource()); + assertEquals("shf", r.getSchema()); + assertEquals("orders", r.getTable()); + } + + @Test + public void constructor_fourArgs_setsAllKeys() { + DrillAccessResource r = new DrillAccessResource( + "mysql", Optional.of("shf"), Optional.of("orders"), Optional.of("amount")); + assertEquals("mysql", r.getDataSource()); + assertEquals("shf", r.getSchema()); + assertEquals("orders", r.getTable()); + assertEquals("amount", r.getValue("column")); + } + + @Test + public void constructor_emptySchema_doesNotSetSchemaKey() { + DrillAccessResource r = new DrillAccessResource( + "mysql", Optional.empty(), Optional.of("orders")); + assertNull(r.getSchema()); + assertNotNull(r.getDataSource()); + assertNotNull(r.getTable()); + } + + @Test + public void constructor_emptyTable_doesNotSetTableKey() { + DrillAccessResource r = new DrillAccessResource( + "mysql", Optional.of("shf"), Optional.empty()); + assertEquals("mysql", r.getDataSource()); + assertEquals("shf", r.getSchema()); + assertNull(r.getTable()); + } + + @Test + public void getCatalogName_returnsDatasource() { + DrillAccessResource r = new DrillAccessResource( + "dfs", Optional.of("tmp"), Optional.of("t1")); + assertEquals("dfs", r.getDataSource()); + } + + @Test + public void getTable_returnsTableValue() { + DrillAccessResource r = new DrillAccessResource( + "dfs", Optional.of("tmp"), Optional.of("t1")); + assertEquals("t1", r.getTable()); + } + + @Test + public void getSchema_returnsSchemaValue() { + DrillAccessResource r = new DrillAccessResource( + "dfs", Optional.of("tmp"), Optional.of("t1")); + assertEquals("tmp", r.getSchema()); + } + + @Test + public void resourceKeys_areLowercase() { + DrillAccessResource r = new DrillAccessResource( + "mysql", Optional.of("shf"), Optional.of("orders"), Optional.of("amount")); + // Ranger requires resource keys to be lowercase; verify that lookups + // with lowercase keys return the registered values. + assertEquals("mysql", r.getValue("datasource")); + assertEquals("shf", r.getValue("schema")); + assertEquals("orders", r.getValue("table")); + assertEquals("amount", r.getValue("column")); + } +} diff --git a/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequestTest.java b/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequestTest.java new file mode 100644 index 00000000000..5ee53800118 --- /dev/null +++ b/auth/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequestTest.java @@ -0,0 +1,156 @@ +/* + * 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.RangerAccessRequest; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link DrillRangerAccessRequest} builder and + * {@link DrillRangerAccessRequest#toRangerRequest()} field mapping. + */ +public class DrillRangerAccessRequestTest { + + private DrillAccessResource buildResource() { + return new DrillAccessResource( + "mysql", + java.util.Optional.of("shf"), + java.util.Optional.of("orders")); + } + + @Test + public void builder_setsAllFields() { + Set groups = new HashSet<>(); + groups.add("g1"); + DrillAccessResource resource = buildResource(); + + DrillRangerAccessRequest request = DrillRangerAccessRequest.builder() + .user("root") + .groups(groups) + .resource(resource) + .accessType(DrillAccessType.SELECT) + .action("select") + .clientIPAddress("10.0.0.1") + .clientType("drill-jdbc") + .build(); + + RangerAccessRequest rangerRequest = request.toRangerRequest(); + assertEquals("root", rangerRequest.getUser()); + assertTrue(rangerRequest.getUserGroups().contains("g1")); + assertEquals(resource, rangerRequest.getResource()); + assertEquals("SELECT", rangerRequest.getAccessType()); + assertEquals("select", rangerRequest.getAction()); + assertEquals("10.0.0.1", rangerRequest.getClientIPAddress()); + assertEquals("drill-jdbc", rangerRequest.getClientType()); + } + + @Test + public void builder_defaultScopeIsSelfOrDescendants() { + DrillRangerAccessRequest request = DrillRangerAccessRequest.builder() + .user("root") + .resource(buildResource()) + .accessType(DrillAccessType.SELECT) + .build(); + assertEquals( + RangerAccessRequest.ResourceMatchingScope.SELF_OR_DESCENDANTS, + request.toRangerRequest().getResourceMatchingScope()); + } + + @Test + public void builder_explicitScope_isApplied() { + DrillRangerAccessRequest request = DrillRangerAccessRequest.builder() + .user("root") + .resource(buildResource()) + .accessType(DrillAccessType.SELECT) + .resourceMatchingScope(RangerAccessRequest.ResourceMatchingScope.SELF) + .build(); + assertEquals( + RangerAccessRequest.ResourceMatchingScope.SELF, + request.toRangerRequest().getResourceMatchingScope()); + } + + @Test + public void builder_addGroup_accumulatesGroups() { + DrillRangerAccessRequest request = DrillRangerAccessRequest.builder() + .user("root") + .resource(buildResource()) + .accessType(DrillAccessType.SELECT) + .addGroup("g1") + .addGroup("g2") + .build(); + + Set groups = request.toRangerRequest().getUserGroups(); + assertTrue(groups.contains("g1")); + assertTrue(groups.contains("g2")); + assertEquals(2, groups.size()); + } + + @Test + public void builder_nullGroups_createsEmptySet() { + DrillRangerAccessRequest request = DrillRangerAccessRequest.builder() + .user("root") + .resource(buildResource()) + .accessType(DrillAccessType.SELECT) + .groups(null) + .build(); + + Set groups = request.toRangerRequest().getUserGroups(); + assertNotNull(groups); + assertTrue(groups.isEmpty()); + } + + @Test + public void toRangerRequest_accessTypeIsUppercase() { + DrillRangerAccessRequest request = DrillRangerAccessRequest.builder() + .user("root") + .resource(buildResource()) + .accessType(DrillAccessType.SELECT) + .build(); + assertEquals("SELECT", request.toRangerRequest().getAccessType()); + } + + @Test + public void toRangerRequest_actionDefaultsToAccessTypeName() { + DrillRangerAccessRequest request = DrillRangerAccessRequest.builder() + .user("root") + .resource(buildResource()) + .accessType(DrillAccessType.SELECT) + .build(); + assertEquals("SELECT", request.toRangerRequest().getAction()); + } + + @Test + public void toRangerRequest_setsResourceMatchingScope() { + DrillRangerAccessRequest request = DrillRangerAccessRequest.builder() + .user("root") + .resource(buildResource()) + .accessType(DrillAccessType.SELECT) + .resourceMatchingScope(RangerAccessRequest.ResourceMatchingScope.SELF) + .build(); + assertEquals( + RangerAccessRequest.ResourceMatchingScope.SELF, + request.toRangerRequest().getResourceMatchingScope()); + } +} diff --git a/auth/pom.xml b/auth/pom.xml new file mode 100644 index 00000000000..6f1da48509e --- /dev/null +++ b/auth/pom.xml @@ -0,0 +1,79 @@ + + + + 4.0.0 + + + org.apache.drill + drill-root + 1.23.0-SNAPSHOT + + + + auth-parent + pom + Drill : Auth Integration Parent + + Parent module aggregating authorization implementations of the + AccessAuthorizer SPI. Currently hosts the Drill Ranger plugin shim + (drill-ranger-plugin-shim) and the Drill Ranger authorization plugin + (drill-ranger-plugin). + + + + 2.9.0 + + 2.35 + + + + drill-ranger-plugin-shim + drill-ranger-plugin + + diff --git a/distribution/pom.xml b/distribution/pom.xml index 10e27292638..ac052af209d 100644 --- a/distribution/pom.xml +++ b/distribution/pom.xml @@ -35,6 +35,15 @@ 1.12.367 3.3.1.0.3.6 + + ${project.build.directory}/ranger-drill-plugin-impl.disabled @@ -68,6 +77,16 @@ drill-java-exec ${project.version} + + + org.apache.drill + drill-security-spi + ${project.version} + org.apache.drill drill-common @@ -361,6 +380,221 @@ + + + ranger + + ${project.build.directory}/ranger-drill-plugin-impl + + + + + org.apache.drill + drill-ranger-plugin-shim + ${project.version} + + + + org.apache.drill + drill-ranger-plugin + ${project.version} + + + org.glassfish.jersey.core + * + + + org.glassfish.jersey.inject + * + + + org.glassfish.hk2 + * + + + org.glassfish.hk2.external + * + + + jakarta.ws.rs + jakarta.ws.rs-api + + + jakarta.annotation + jakarta.annotation-api + + + jakarta.inject + jakarta.inject-api + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-ranger-plugin-isolated-deps + prepare-package + + copy + + + + + + + jakarta.ws.rs + jakarta.ws.rs-api + ${jaxrs.api.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + + org.glassfish.jersey.core + jersey-client + ${jersey.ranger.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + org.glassfish.jersey.core + jersey-common + ${jersey.ranger.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + org.glassfish.jersey.inject + jersey-hk2 + ${jersey.ranger.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + org.glassfish.jersey.core + jersey-server + ${jersey.ranger.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + org.glassfish.jersey.media + jersey-media-json-jackson + ${jersey.ranger.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + org.glassfish.jersey.ext + jersey-entity-filtering + ${jersey.ranger.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + + org.glassfish.hk2 + hk2-locator + ${hk2.ranger.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + org.glassfish.hk2 + hk2-api + ${hk2.ranger.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + org.glassfish.hk2 + hk2-utils + ${hk2.ranger.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + org.glassfish.hk2.external + aopalliance-repackaged + ${hk2.ranger.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + org.glassfish.hk2 + osgi-resource-locator + ${osgi.resource.locator.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + jakarta.annotation + jakarta.annotation-api + ${jakarta.annotation.api.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + jakarta.inject + jakarta.inject-api + ${jakarta.inject.api.version} + jar + ${project.build.directory}/ranger-drill-plugin-impl + + + + + + + + + + build-jdbc-all diff --git a/distribution/src/assemble/component.xml b/distribution/src/assemble/component.xml index 71974cdd127..e98a98fd1a2 100644 --- a/distribution/src/assemble/component.xml +++ b/distribution/src/assemble/component.xml @@ -65,6 +65,8 @@ org.apache.drill:drill-common:jar org.apache.drill:drill-logical:jar org.apache.drill:drill-protocol:jar + org.apache.drill:drill-ranger-plugin-shim:jar + org.apache.drill:drill-security-spi:jar org.apache.drill.exec:drill-java-exec:jar org.apache.drill.exec:drill-jdbc:jar org.apache.drill.exec:drill-rpc:jar @@ -98,6 +100,61 @@ false + + + + + org.apache.drill:drill-ranger-plugin:jar + + org.apache.ranger:ranger-plugins-common:jar + org.apache.ranger:ranger-knox-plugin:jar + org.apache.ranger:ranger-audit-core:jar + org.apache.ranger:ranger-audit-dest-log4j:jar + org.apache.ranger:ranger-authz-api:jar + org.apache.ranger:ranger-common-utils:jar + org.apache.ranger:ugsync-util:jar + + jars/ranger-drill-plugin-impl + false + false + + + + + + org.apache.ranger:ranger-plugin-classloader:jar + + jars/3rdparty + false + false + compile + + jars/classb false @@ -125,6 +182,22 @@ org.jvnet.mimepull org.reflections + + + org.glassfish.jersey.core:jersey-client:jar:${jersey.ranger.version} + org.glassfish.jersey.core:jersey-common:jar:${jersey.ranger.version} + org.glassfish.jersey.core:jersey-server:jar:${jersey.ranger.version} + org.glassfish.jersey.inject:jersey-hk2:jar:${jersey.ranger.version} + org.glassfish.jersey.media:jersey-media-json-jackson:jar:${jersey.ranger.version} + org.glassfish.jersey.media:jersey-media-multipart:jar:${jersey.ranger.version} + org.glassfish.jersey.ext:jersey-entity-filtering:jar:${jersey.ranger.version} + jakarta.ws.rs:jakarta.ws.rs-api:jar:${jaxrs.api.version} + jars/3rdparty/ @@ -152,10 +225,16 @@ org.apache.drill.exec org.apache.drill.memory org.apache.drill.metastore + + org.apache.ranger + org.glassfish.hk2.external + org.glassfish.hk2 org.apache.zookeeper org.eclipse.jetty - org.glassfish.hk2 - org.glassfish.hk2.external org.glassfish.jersey.containers org.glassfish.jersey.core org.glassfish.jersey.ext @@ -216,10 +295,27 @@ ../sample-data sample-data + + + ${ranger.impl.source.dir} + jars/ranger-drill-plugin-impl + ${project.build.directory}/winutils winutils/bin + + src/main/resources/ranger + conf/ranger + 0640 + diff --git a/distribution/src/main/resources/drill-config.sh b/distribution/src/main/resources/drill-config.sh index 8037a8faea6..51b8720c15d 100644 --- a/distribution/src/main/resources/drill-config.sh +++ b/distribution/src/main/resources/drill-config.sh @@ -365,6 +365,11 @@ export DRILLBIT_LOG_PATH="${DRILL_LOG_PREFIX}.log" # Add Drill conf folder at the beginning of the classpath CP="$DRILL_CONF_DIR" +# Add Ranger config directory if it exists (for ranger-drill-security.xml etc.) +if [ -d "$DRILL_CONF_DIR/ranger" ]; then + CP="$CP:$DRILL_CONF_DIR/ranger" +fi + # If both user and YARN-provided Java lib paths exist, # combine them. diff --git a/distribution/src/main/resources/logback.xml b/distribution/src/main/resources/logback.xml index 16bf2c7b7e0..ca7e7111210 100644 --- a/distribution/src/main/resources/logback.xml +++ b/distribution/src/main/resources/logback.xml @@ -73,6 +73,14 @@ + + + + + diff --git a/distribution/src/main/resources/ranger/ranger-drill-audit.xml b/distribution/src/main/resources/ranger/ranger-drill-audit.xml new file mode 100644 index 00000000000..19369073054 --- /dev/null +++ b/distribution/src/main/resources/ranger/ranger-drill-audit.xml @@ -0,0 +1,75 @@ + + + + + + + + + xasecure.audit.is.enabled + true + + + + + xasecure.audit.log4j.is.enabled + true + + + + + xasecure.audit.solr.is.enabled + false + + + xasecure.audit.solr.url + http://ranger-admin-host:6083/solr/ranger_audits + + + + + xasecure.audit.hdfs.is.enabled + false + + + xasecure.audit.hdfs.config.directory + hdfs://namenode:8020/ranger/audit + + + xasecure.audit.hdfs.config.file + /etc/hadoop/conf/core-site.xml + + diff --git a/distribution/src/main/resources/ranger/ranger-drill-security.xml b/distribution/src/main/resources/ranger/ranger-drill-security.xml new file mode 100644 index 00000000000..70a7096c3c4 --- /dev/null +++ b/distribution/src/main/resources/ranger/ranger-drill-security.xml @@ -0,0 +1,57 @@ + + + + + + + ranger.plugin.drill.policy.rest.url + http://ranger-admin-host:6080 + + + + + ranger.plugin.drill.service.name + drill + + + + + ranger.plugin.drill.policy.source.impl + org.apache.ranger.admin.client.RangerAdminJersey2RESTClient + + + + + ranger.plugin.drill.policy.pollIntervalMs + 30000 + + + + + ranger.plugin.drill.policy.cache.dir + /tmp/ranger/drill/policy + + diff --git a/docs/dev/DevDocs.md b/docs/dev/DevDocs.md index eef8105b4d6..8c2df97c6f7 100644 --- a/docs/dev/DevDocs.md +++ b/docs/dev/DevDocs.md @@ -27,3 +27,10 @@ For information about the Jetty 12 upgrade, known limitations, and developer gui ## Materialized Views For information about materialized view support, including SQL syntax, query rewriting, and metastore integration, see [MaterializedViews.md](MaterializedViews.md) + +For information on the Jetty 12 upgrade, known limitations, and developer guidelines see [Jetty12Migration.md](Jetty12Migration.md) + +## Ranger Authorization + +For information on developing and configuring Apache Ranger column-level authorization for Drill see [RangerAuthorization.md](RangerAuthorization.md) + diff --git a/docs/dev/RangerAuthorization.md b/docs/dev/RangerAuthorization.md new file mode 100644 index 00000000000..09126685521 --- /dev/null +++ b/docs/dev/RangerAuthorization.md @@ -0,0 +1,383 @@ +# Drill Ranger Authorization Quick Start Guide + +This document describes the architecture, configuration, and development +conventions of the Apache Ranger authorization integration for Drill. It is +intended for contributors who want to extend or debug the Ranger integration, +and for operators who want to understand the column-level authorization +behavior end-to-end. + +## 1. Architecture Overview + +The Ranger integration spans three layers: + +``` ++--------------------------------------------------------------+ +| exec/java-exec (Drillbit, JDK 11) | +| +-------------------------+ +------------------------+ | +| | SqlConverter (toRel) | ---> | ColumnAccessChecker | | +| | DrillCalciteCatalogReader| | (RelShuttle, column) | | +| | Drillbit (startup) | +------------------------+ | +| +-------------------------+ | | +| v | +| +-------------------------------+ +-----------------------+ | +| | AccessAuthorizerFactory | | DrillAccessControl | | +| | (singleton, config-driven) | | (SPI implementation) | | +| +-------------------------------+ +-----------------------+ | +| | | +| +----------------------------------------------------------------+ +| | drill-ranger-plugin (JDK 11, deployed to jars/3rdparty/) | +| | DrillAuthorizer DrillAccessResource DrillRangerAccessRequest| +| | RangerDrillPlugin RangerBaseAuthorizer | +| +----------------------------------------------------------------+ +``` + +The Ranger Admin-side service plugin (`validateConfig` / `lookupResource`, +which talks to the Drill REST API `POST /query.json`) is implemented directly +in the Ranger codebase, not in this repository. + +### 1.1 Modules + +| Module | JDK | Deployed To | Responsibility | +| --------------------- | --- | ------------------------- | ------------------------------------------------------------------------------------------------- | +| `drill-ranger-plugin` | 11 | Drillbit `jars/3rdparty/` | Drillbit-side authorization: wraps `RangerBasePlugin`, exposes `DrillAccessControl` facade | +| `exec/security-spi` | 11 | Drillbit `jars/` | JDK-only authorization SPI (`AccessAuthorizer`, `UserIdentity`, ...); shared by engine and plugin | +| `exec/java-exec` | 11 | Drillbit | Integration hooks: `AccessAuthorizerFactory`, `ColumnAccessChecker`, `DrillCalciteCatalogReader` | + +## 2. Resource Model + +Ranger policies for Drill use a **four-level resource hierarchy**: + +``` +datasource → schema → table → column +``` + +| Level | Ranger resource key | Example | Notes | +| ---------- | ------------------- | ------------------- | ------------------------------------- | +| datasource | `datasource` | `mysql` | Drill storage plugin name | +| schema | `schema` | `shf` | Schema path WITHOUT datasource prefix | +| table | `table` | `orders` | Table name | +| column | `column` | `id`, `amount`, `*` | `*` matches all columns | + +**Critical conventions**: + +* Resource keys must be **lowercase** (`datasource`, not `DATASOURCE`). Ranger + validates names against `[a-z_-]` only (error code 2022). + +* The `schema` value must NOT include the datasource prefix. Use `shf`, not + `mysql.shf`. + +* Access type name in the service-def must exactly match what the code sends — + both uppercase `SELECT`. + +### 2.1 DDL Authorization (CREATE / DROP) + +Besides the table- and column-level `SELECT` checks issued during SQL +validation (in `DrillCalciteCatalogReader` / `ColumnAccessChecker`), the +engine checks CREATE/DROP privileges on the **target object** of DDL +statements, in the DDL handlers themselves (`DdlAccessChecker`): + +| Statement | AccessType checked | Resource checked | +| ----------------------------------- | ------------------ | ----------------- | +| `CREATE TABLE ... AS SELECT` (CTAS) | `CREATE` | the new table | +| `CREATE VIEW ... AS SELECT` | `CREATE` | the new view | +| `DROP TABLE` | `DROP` | the dropped table | +| `DROP VIEW` | `DROP` | the dropped view | + +Semantics: + +* **Two-layer checks for CTAS / CREATE VIEW**: the query part is still subject + to the `SELECT` checks above (source tables and columns), and the target + object additionally requires `CREATE`. A statement is executed only when + both checks pass. + +* **`CREATE VIEW OR REPLACE`** is treated as a single CREATE operation — no + extra DROP privilege is required. + +* **Temporary tables** (`CREATE TEMPORARY TABLE`, dropping a temporary table) + are session-scoped objects (UUID names, invisible to other users) and + bypass authorization. + +* **Authorization before existence checks**: the permission check runs before + "table not found" style errors, so an unauthorized user cannot probe object + existence through differing error messages. + +* **Denial surfaces as a** **`PERMISSION ERROR`** (`UserException.permissionError`), + exactly like a denied SELECT. + +* Resource mapping reuses `TableAccessResource.resolve` — the same + datasource/schema/table triple the SELECT checks address. For objects in + schema-less locations (e.g. `dfs` root) the default schema `default` is + synthesized (`datasource=dfs, schema=default`). + +A sample policy allowing all DDL creates in a schema (table-level +`SELF_OR_DESCENDANTS` matching, so `table=*` covers every table): + +| Field | Value | +| ----------- | -------- | +| datasource | `dfs` | +| schema | `tmp` | +| table | `*` | +| access type | `CREATE` | +| user/group | (user) | + +> **Upgrade note (behavior change)**: once authorization is enabled, users +> without a `CREATE` policy are now denied CTAS / CREATE VIEW (previously +> allowed when only SELECT was checked). Same for `DROP`. Ranger denies by +> default — plan policies accordingly when enabling. + +## 3. Configuration + +### 3.1 Drillbit side (`drill-module.conf`) + +```hocon +drill.exec.security.ranger: { + enabled: true, + service.name: "drill", + impl: "org.apache.drill.exec.security.ranger.RangerAccessAuthorizer" +} +``` + +| Key | Default | Description | +| ----------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------- | +| `drill.exec.security.ranger.enabled` | `false` | Master switch. `false` → `AllowAllAccessAuthorizer` (fail-open) | +| `drill.exec.security.ranger.service.name` | `"drill"` | Ranger service name registered in Ranger Admin | +| `drill.exec.security.ranger.impl` | `org.apache.drill.exec.security.ranger.RangerAccessAuthorizer` | `AccessAuthorizer` implementation class | + +### 3.2 Ranger Admin side + +* `drill.connection.url` — Drill REST API URL, e.g. `http://drillbit-host:8047`. + Bare `host:port` is normalized to `http://host:port`. + +* `username` / `password` — Drill user for `validateConfig` and + `lookupResource` REST calls (HTTP Basic auth). + +### 3.3 Deployment Steps + +After building the distribution, two deployment actions are required to make +Ranger Admin recognize Drill as an authorization provider. + +#### Step 1: Update Ranger config files in Drill + +Copy the Ranger configuration files into Drill's `conf/` directory and edit +them to match your environment. + +```bash +DRILL_HOME=/opt/drill + +cp distribution/src/main/resources/ranger/ranger-drill-security.xml $DRILL_HOME/conf/ +cp distribution/src/main/resources/ranger/ranger-drill-audit.xml $DRILL_HOME/conf/ +``` + +Then edit `$DRILL_HOME/conf/ranger-drill-security.xml`: + +| Property | Value to set | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `ranger.plugin.drill.policy.rest.url` | `http://:6080` | +| `ranger.plugin.drill.service.name` | The Ranger service name (must match `drill.exec.security.ranger.service.name` in `drill-override.conf`) | + + +### 3.4 Ranger policy files + +| File | Location | Purpose | +| ------------------------------ | ----------------------------------------- | -------------------------------------------------------------- | +| `ranger-drill-security.xml` | `distribution/src/main/resources/ranger/` | Ranger plugin config (policy cache dir, polling interval) | +| `ranger-drill-audit.xml` | `distribution/src/main/resources/ranger/` | Audit sink config (HDFS, Solr, etc.) | + +### 3.5 Audit Log Configuration + +By default, Ranger audit records are written to the **Drillbit log** via log4j. +This is the simplest setup and requires no external dependencies. The default +values in `ranger-drill-audit.xml` are: + +| Property | Default | Description | +| -------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------- | +| `xasecure.audit.is.enabled` | `true` | **Master switch.** Must be `true` for any audit destination to work. | +| `xasecure.audit.log4j.is.enabled` | `true` | Audit to log4j (Drillbit log). **Enabled by default.** | +| `xasecure.audit.solr.is.enabled` | `false` | Audit to a Solr collection. Disabled by default. | +| `xasecure.audit.solr.url` | `http://ranger-admin-host:6083/solr/ranger_audits` | Solr endpoint (used only when `solr.is.enabled=true`). | +| `xasecure.audit.hdfs.is.enabled` | `false` | Audit to HDFS. Disabled by default. | +| `xasecure.audit.hdfs.config.directory` | `hdfs://namenode:8020/ranger/audit` | HDFS audit directory (used only when `hdfs.is.enabled=true`). | +| `xasecure.audit.hdfs.config.file` | `/etc/hadoop/conf/core-site.xml` | Hadoop config file for HDFS client (used only when `hdfs.is.enabled=true`). | + +> **Property name caveat:** The property names above are verified from +> `AuditProviderFactory` bytecode in `ranger-audit-core-2.8.0.jar`. The older +> names `xasecure.audit.is.audit.to.{log4j,solr,hdfs}` are **not** read by +> `AuditProviderFactory` and have no effect. + +When `xasecure.audit.log4j.is.enabled=true`, `AuditProviderFactory` loads +`org.apache.ranger.audit.provider.Log4jAuditProvider` (from +`ranger-audit-dest-log4j` jar) via `Class.forName()`. That class logs audit +events through an SLF4J logger named +`xaaudit.org.apache.ranger.audit.provider.Log4jAuditProvider` +(the prefix `xaaudit.` is prepended to the class name in the static +initializer). + +**Important:** For audit records to reach `drillbit.log`, a logger entry for +this logger name must be present in `logback.xml`. The shipped +`distribution/src/main/resources/logback.xml` already includes this entry: + +```xml + + + +``` + +Without this entry, audit events (logged at INFO) fall through to the root +logger (`error` level, STDOUT only) and are silently dropped. + +#### Verifying audit output in drillbit.log + +1. **Ranger Admin side** — create a policy that either allows or denies the + test user access to a table (e.g. `mysql.shf.orders`). Make sure the policy + is saved and the Drillbit has pulled it (default poll interval is 30 s). + +2. **Drill side** — run a query that triggers an authorization decision: + + ```sql + SELECT id FROM mysql.shf.orders; + ``` + +3. **Check drillbit.log** — look for audit entries from the + `xaaudit.org.apache.ranger.audit.provider.Log4jAuditProvider` logger: + + ```bash + grep -i "xaaudit\|ranger\|audit\|access" $DRILL_HOME/log/drillbit.log | tail -20 + ``` + + A typical audit log line looks like: + + ``` + 2026-08-11 10:30:45,123 [...] INFO xaaudit.org.apache.ranger.audit.provider.Log4jAuditProvider - + accessType=SELECT resource=mysql.shf.orders reqUser=alice ... + action=accessAllowed result=1 + ``` + + For a denied query, `action=accessDenied` / `result=0` is logged instead. + If nothing appears, verify: + + * `ranger-audit-dest-log4j-2.8.0.jar` is present in `$DRILL_HOME/jars/3rdparty/` + + * `xasecure.audit.is.enabled=true` and `xasecure.audit.log4j.is.enabled=true` + in `$DRILL_HOME/conf/ranger/ranger-drill-audit.xml` + + * The `logback.xml` entry above is present + + * The Drillbit was restarted after editing configuration + +#### Switching the audit destination + +To send audit records to **Solr** instead of (or in addition to) the Drillbit +log, edit `$DRILL_HOME/conf/ranger/ranger-drill-audit.xml` after deployment: + +```xml + + xasecure.audit.solr.is.enabled + true + + + xasecure.audit.solr.url + http://your-ranger-admin-host:6083/solr/ranger_audits + +``` + +To send audit records to **HDFS**: + +```xml + + xasecure.audit.hdfs.is.enabled + true + + + xasecure.audit.hdfs.config.directory + hdfs://your-namenode:8020/ranger/audit + + + xasecure.audit.hdfs.config.file + /etc/hadoop/conf/core-site.xml + +``` + +Multiple sinks can be enabled simultaneously — for example, keep `log4j=true` +as a local fallback while also forwarding to Solr for centralized search. After +changing the file, restart the Drillbit for the new settings to take effect. + +## 4. Authorization Policy Test Cases + +The following test cases document the expected authorization behavior with the +sample policies below. All SQL runs against tables `mysql.shf.orders` and +`mysql.shf.users`. + +### 4.1 Sample Ranger Policies + +**Policy A — users table, all columns** + +| Field | Value | +| ----------- | ----------------- | +| datasource | `mysql` | +| schema | `shf` | +| table | `users` | +| column | `*` | +| access type | `SELECT` | +| user/group | (authorized user) | + +**Policy B — orders table, specific columns only** + +| Field | Value | +| ----------- | ----------------- | +| datasource | `mysql` | +| schema | `shf` | +| table | `orders` | +| column | `id`, `amount` | +| access type | `SELECT` | +| user/group | (authorized user) | + +**Policy C — DDL creates in the shf schema** + +| Field | Value | +| ----------- |-------------------| +| datasource | `mysql`,`dfs` | +| schema | `shf` ,`test` | +| table | `*` | +| access type | `CREATE` | +| user/group | (authorized user) | + +Under these policies, the `orders.user_id` and `orders.order_date` columns are +NOT authorized. The `users` table allows all columns via `*`. Policy C grants +CTAS / CREATE VIEW (but not DROP) anywhere in `dfs.test`. + +### 4.2 Test Cases + +| # | SQL | Expected | Why | +| -- |-----------------------------------------------------------------------------------------------------------------------| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | `SELECT id, amount FROM orders` | **PASS** | `id`, `amount` both in Policy B | +| 2 | `SELECT * FROM orders` | **DENY** | `*` expands to all columns including `user_id`, which is not in Policy B | +| 3 | `SELECT sum(id) FROM orders` | **PASS** | Aggregate on authorized column `id` | +| 4 | `SELECT id FROM orders WHERE user_id > 1` | **DENY** | `user_id` in WHERE clause is checked (LogicalFilter condition traced) | +| 5 | `SELECT sum(user_id) FROM orders` | **DENY** | `user_id` not in Policy B | +| 6 | `SELECT o.id, u.name, o.amount, o.order_date FROM orders o INNER JOIN users u ON o.user_id = u.id` | **DENY** | `o.user_id` appears in JOIN condition; `o.order_date` not in Policy B | +| 7 | `SELECT o.id, u.name, o.amount FROM orders o INNER JOIN users u ON o.id = u.id` | **PASS** | All referenced columns authorized: `o.id`, `u.name` (via `*`), `o.amount` | +| 8 | `SELECT u.*, o.amount FROM orders o JOIN users u ON o.id = u.id WHERE o.amount > (SELECT AVG(amount) FROM orders)` | **PASS** | `u.*` authorized via Policy A; `o.amount` and subquery `amount` in Policy B | +| 9 | `SELECT o.*, u.name FROM orders o JOIN users u ON o.user_id = u.id WHERE o.amount > (SELECT AVG(amount) FROM orders)` | **DENY** | `o.*` expands to `user_id` (not authorized); `o.user_id` in JOIN condition | +| 10 | `SELECT o.id, u.name FROM orders o JOIN users u ON o.id = u.id WHERE o.amount > (SELECT AVG(amount) FROM orders)` | **PASS** | All columns authorized; subquery only references `amount` | +| 11 | `SELECT o.id, u.name FROM orders o JOIN users u ON o.id = u.id WHERE o.amount > (SELECT sum(user_id) FROM orders)` | **DENY** | Scalar subquery references `user_id` (not authorized). **Requires RexSubQuery handling.** | +| 12 | `SELECT name FROM users WHERE id IN (SELECT DISTINCT user_id FROM orders)` | **DENY** | IN-subquery references `user_id` (not authorized). **Requires RexSubQuery handling.** | +| 13 | `SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)` | **DENY** | EXISTS-subquery references `o.user_id` (not authorized). **Requires RexSubQuery handling.** | +| 14 | `WITH t AS (SELECT * FROM mysql.shf.orders) SELECT * FROM t` | **DENY** | CTE body `SELECT *` expands to all columns including `order_date` (not in Policy B). CTE is inlined before `ColumnAccessChecker` runs, so the underlying `orders` TableScan is checked. | +| 15 | `WITH t AS (SELECT id FROM mysql.shf.orders) SELECT * FROM t` | **PASS** | CTE body only selects `id` (in Policy B); outer `SELECT *` from CTE resolves to the same authorized column. | +| 16 | `WITH t AS (SELECT id, order_date FROM mysql.shf.orders) SELECT order_date FROM t` | **DENY** | CTE body selects `order_date` (not in Policy B). Column check traces back to the underlying `orders` TableScan after CTE inlining. | +| 17 | `WITH t AS (SELECT id, order_date FROM mysql.shf.orders) SELECT id FROM t` | **DENY** | Even though the outer query only projects `id`, the CTE body references `order_date` (not in Policy B). After inlining, the `orders` TableScan has both columns referenced, so the check fails on `order_date`. | +| 18 | `WITH a AS (SELECT id FROM mysql.shf.orders), b AS (SELECT id FROM mysql.shf.users) SELECT * FROM b` | **PASS** | Multiple CTEs: `a` selects `id` from `orders` (Policy B); `b` selects `id` from `users` (Policy A via `*`). Outer query selects from `b` only. All referenced columns authorized. | +| 19 | `SELECT o.order_date FROM mysql.shf.orders o` | **DENY** | Table alias is transparent: `o.order_date` resolves to `orders.order_date` (not in Policy B). `RexInputRef` indexes point to row-type positions, and `getColumnOrigins` traces through to the underlying `TableScan` regardless of alias. | +| 20 | `SELECT a.id FROM mysql.shf.users a JOIN mysql.shf.orders b ON a.id = b.id` | **PASS** | Join with aliases: `a.id` resolves to `users.id` (Policy A via `*`); `b.id` resolves to `orders.id` (Policy B). Join condition columns also authorized. | +| 21 | `SELECT o.id FROM mysql.shf.orders o` | **PASS** | Simple table alias on an authorized column; `o.id` resolves to `orders.id` (Policy B). | +| 22 | `SELECT o.id FROM mysql.shf.orders o WHERE o.amount = '150.0'` | **PASS** | Alias in WHERE clause on an authorized column; `o.amount` resolves to `orders.amount` (Policy B). | +| 23 | `SELECT o.id FROM mysql.shf.orders o WHERE EXISTS (SELECT 1 FROM mysql.shf.users u WHERE u.id = o.user_id)` | **DENY** | Correlated subquery: the outer-column reference `o.user_id` (represented as a `RexFieldAccess` over a `RexCorrelVariable`, not a `RexInputRef`) resolves to `orders.user_id`, which is not in Policy B. The outer query is NOT `SELECT *`, so nothing else covers `orders.user_id` — the denial comes solely from the correlated reference. **Requires** **`RexFieldAccess`/`RexCorrelVariable`** **handling**; without it this column is silently bypassed. (Case 13 does not catch this because its outer `SELECT *` over `users` masks the bypass and its correlated column `u.id` is authorized via Policy A.) | +| 24 | `SELECT o.id FROM mysql.shf.orders o WHERE EXISTS (SELECT 1 FROM mysql.shf.users u WHERE u.id = o.id)` | **PASS** | Same shape as case 23, but the correlated reference `o.id` resolves to `orders.id` (Policy B). Confirms that correlated references to authorized outer columns are still allowed. | +| 25 | `SELECT SUM(o.amount) FILTER (WHERE o.user_id > 0) FROM mysql.shf.orders o` | **DENY** | Aggregate `FILTER (WHERE ...)` clause: `o.user_id` is not in Policy B. In the Rel tree, Calcite lowers the FILTER predicate into a boolean column beneath the `LogicalAggregate`, and the `AggregateCall.filterArg` ordinal points at that column. `visit(LogicalAggregate)` traces `filterArg` explicitly so the unauthorized `orders.user_id` is caught even without relying on an implicit Project beneath. **Without the** **`filterArg`** **trace this column is silently bypassed.** | +| 26 | `SELECT SUM(o.amount) FILTER (WHERE o.id > 0) FROM mysql.shf.orders o` | **PASS** | Same shape as case 25, but the FILTER clause references `o.id` (Policy B). Confirms that aggregate call arguments and FILTER columns that are authorized are still allowed. **Note:** The `FILTER (WHERE ...)` clause is SQL:2008 standard but not supported by MySQL; the permission check passes but query execution will fail on MySQL storage. Use PostgreSQL or a Drill-native table for end-to-end execution, or use `SELECT SUM(CASE WHEN id > 0 THEN amount END) FROM ...` as a MySQL-compatible alternative (though `CASE` goes through `argList`, not `filterArg`). | +| 27 | `CREATE TABLE mysql.shf.new_orders AS SELECT id, amount FROM mysql.shf.orders` | **PASS** | Target table `new_orders` covered by Policy C (`CREATE` on `mysql.shf.*`); query part references only `id`, `amount` (Policy B). Both layers pass. | +| 28 | `CREATE TABLE mysql.shf.new_orders AS SELECT * FROM mysql.shf.orders` | **DENY** | `CREATE` on the target is granted (Policy C), but the query part's `SELECT *` expands to unauthorized columns (`user_id`, `order_date` — not in Policy B). The source SELECT check rejects the statement before the target CREATE check matters. | +| 29 | `DROP TABLE mysql.shf.orders` | **DENY** | No policy grants `DROP` on `mysql.shf.*` (Policy C is CREATE-only). The DDL check in `DropTableHandler` denies with a PERMISSION ERROR before the existence check. | +| 30 | `use dfs.test; CREATE VIEW v_orders AS SELECT id FROM mysql.shf.orders; DROP VIEW v_orders` | **PASS / DENY** | `CREATE VIEW` passes (Policy C, query part only references `id` in Policy B). `DROP VIEW` is denied: no `DROP` policy. `OR REPLACE` on the view would still be a single CREATE check (PASS). Temporary tables bypass both checks entirely. | + diff --git a/exec/java-exec/pom.xml b/exec/java-exec/pom.xml index fe2c229a9a0..92dda18d79f 100644 --- a/exec/java-exec/pom.xml +++ b/exec/java-exec/pom.xml @@ -318,6 +318,17 @@ drill-logical ${project.version} + + + org.apache.drill + drill-security-spi + ${project.version} + org.apache.drill.exec drill-rpc diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/ExecConstants.java b/exec/java-exec/src/main/java/org/apache/drill/exec/ExecConstants.java index b511daa9c2f..febcc64ded7 100644 --- a/exec/java-exec/src/main/java/org/apache/drill/exec/ExecConstants.java +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/ExecConstants.java @@ -313,6 +313,10 @@ private ExecConstants() { public static final String BIT_ENCRYPTION_SASL_ENABLED = "drill.exec.security.bit.encryption.sasl.enabled"; public static final String BIT_ENCRYPTION_SASL_MAX_WRAPPED_SIZE = "drill.exec.security.bit.encryption.sasl.max_wrapped_size"; + // Access authorization (generic; Ranger is one provider selected via 'name') + public static final String AUTHORIZER_ENABLED = "drill.exec.security.authorizer.enabled"; + public static final String AUTHORIZER_NAME = "drill.exec.security.authorizer.name"; + /** Size of JDBC batch queue (in batches) above which throttling begins. */ public static final String JDBC_BATCH_QUEUE_THROTTLING_THRESHOLD = "drill.jdbc.batch_queue_throttling_threshold"; diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/ColumnAccessChecker.java b/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/ColumnAccessChecker.java new file mode 100644 index 00000000000..3fd5b674e46 --- /dev/null +++ b/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). + * + *

Design: 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 Map> 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 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 referencedColIndices = tableToReferencedCols.get(table); + List allColumnNames = scan.getRowType().getFieldNames(); + + Set 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 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 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 refs = new HashSet<>(); + List subQueries = new ArrayList<>(); + List 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 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. + * + *

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). + * + *

{@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 RexVisitorImpl { + private final Set refs; + private final List subQueries; + private final List correlAccesses; + + RexRefCollector(Set refs, List subQueries, + List 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); + } + correlAccesses.add(new CorrelFieldAccess(varName, + fieldAccess.getField().getIndex())); + } + // Preserve default descent so that non-correlated field accesses (e.g. + // struct field access over a regular column) continue to be traversed + // exactly as before this override. + return super.visitFieldAccess(fieldAccess); + } + } +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/DrillCalciteCatalogReader.java b/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/DrillCalciteCatalogReader.java index 53644d4c094..2316b0fb76c 100644 --- a/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/DrillCalciteCatalogReader.java +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/DrillCalciteCatalogReader.java @@ -35,6 +35,11 @@ import org.apache.drill.exec.planner.logical.DrillTable; import org.apache.drill.exec.planner.sql.SchemaUtilities; 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 com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; @@ -103,14 +108,53 @@ void disallowTemporaryTables() { public Prepare.PreparingTable getTable(List names) { checkTemporaryTable(names); Prepare.PreparingTable table = super.getTable(names); - DrillTable drillTable; - if (table != null && (drillTable = table.unwrap(DrillTable.class)) != null) { - drillTable.setOptions(session.getOptions()); - drillTable.setTableMetadataProviderManager(tableCache.getUnchecked(DrillTableKey.of(names, drillTable))); + if (table != null) { + // Ranger SELECT authorization check for ALL table types, including + // JDBC storage plugin tables (JdbcTable) that are not DrillTable. + checkTableAccess(table); + + DrillTable drillTable = table.unwrap(DrillTable.class); + if (drillTable != null) { + drillTable.setOptions(session.getOptions()); + drillTable.setTableMetadataProviderManager(tableCache.getUnchecked(DrillTableKey.of(names, drillTable))); + } } return table; } + /** + * Checks SELECT permission on the resolved table via the configured {@link AccessAuthorizer} + * (Ranger by default). No-op when authorization is disabled (fail-open). System schemas + * (INFORMATION_SCHEMA, sys) are bypassed inside the authorizer implementation. + * + *

Extracts 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 names) { if (allowTemporaryTables || !needsTemporaryTableCheck(names, session.getDefaultSchemaPath(), drillConfig)) { return; diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/SqlConverter.java b/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/SqlConverter.java index 25ed545c687..1f80fe42fc1 100644 --- a/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/SqlConverter.java +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/SqlConverter.java @@ -62,6 +62,7 @@ import org.apache.drill.exec.planner.sql.parser.impl.DrillSqlParseException; import org.apache.drill.exec.planner.types.DrillRelDataTypeSystem; import org.apache.drill.exec.rpc.user.UserSession; +import org.apache.drill.exec.security.AccessAuthorizerManager; import org.apache.drill.exec.util.ImpersonationUtil; import org.apache.drill.exec.util.Utilities; import org.slf4j.Logger; @@ -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); + } + return rel.withRel(sqlToRelConverter.flattenTypes(rel.rel, true)); } diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/handlers/CreateTableHandler.java b/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/handlers/CreateTableHandler.java index dde067a1130..6e88a8a74cd 100644 --- a/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/handlers/CreateTableHandler.java +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/handlers/CreateTableHandler.java @@ -43,6 +43,9 @@ import org.apache.drill.exec.physical.base.PhysicalOperator; import org.apache.drill.exec.planner.sql.DirectPlan; import org.apache.drill.exec.rpc.user.UserSession; +import org.apache.drill.exec.security.AccessAuthorizerManager; +import org.apache.drill.exec.security.DdlAccessChecker; +import org.apache.drill.exec.security.spi.AccessType; import org.apache.drill.exec.store.StorageStrategy; import org.apache.drill.exec.planner.logical.DrillRel; import org.apache.drill.exec.planner.logical.DrillScreenRel; @@ -87,6 +90,14 @@ public PhysicalPlan getPlan(SqlNode sqlNode) throws ValidationException, RelConv final boolean checkTableNonExistence = sqlCreateTable.checkTableNonExistence(); final String schemaPath = drillSchema.getFullSchemaName(); + // Ranger DDL authorization: CREATE privilege on the new table. Temporary + // tables are session-scoped objects (UUID name, invisible to other users) + // and bypass authorization. Checked before the existence check so an + // unauthorized user cannot probe table existence via differing errors. + if (!sqlCreateTable.isTemporary() && AccessAuthorizerManager.isEnabled(config.getContext().getConfig())) { + DdlAccessChecker.checkDdlAccess(context, drillSchema, originalTableName, AccessType.CREATE); + } + // Check table creation possibility if(!checkTableCreationPossibility(drillSchema, originalTableName, drillConfig, context.getSession(), schemaPath, checkTableNonExistence)) { return DirectPlan.createDirectPlan(context, false, diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/handlers/DropTableHandler.java b/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/handlers/DropTableHandler.java index c9f5e7f5bb3..69148961820 100644 --- a/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/handlers/DropTableHandler.java +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/handlers/DropTableHandler.java @@ -31,6 +31,9 @@ import org.apache.drill.exec.planner.sql.SchemaUtilities; import org.apache.drill.exec.planner.sql.parser.SqlDropTable; import org.apache.drill.exec.rpc.user.UserSession; +import org.apache.drill.exec.security.AccessAuthorizerManager; +import org.apache.drill.exec.security.DdlAccessChecker; +import org.apache.drill.exec.security.spi.AccessType; import org.apache.drill.exec.store.AbstractSchema; // SqlHandler for dropping a table. @@ -67,6 +70,13 @@ public PhysicalPlan getPlan(SqlNode sqlNode) { session.removeTemporaryTable(temporarySchema, originalTableName, drillConfig); } else { AbstractSchema drillSchema = SchemaUtilities.resolveToMutableDrillSchema(defaultSchema, tableSchema); + + // Ranger DDL authorization: DROP privilege on the target table. + // Checked before the existence check so an unauthorized user cannot + // probe table existence via differing errors. + if (AccessAuthorizerManager.isEnabled(config.getContext().getConfig())) { + DdlAccessChecker.checkDdlAccess(context, drillSchema, originalTableName, AccessType.DROP); + } Table tableToDrop = SqlHandlerUtil.getTableFromSchema(drillSchema, originalTableName); // TableType.OTHER started getting reported for H2 DB when it was upgraded to v2. if (tableToDrop == null || (tableToDrop.getJdbcTableType() != Schema.TableType.TABLE && diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/handlers/ViewHandler.java b/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/handlers/ViewHandler.java index 089de0ce83d..94a8f31519c 100644 --- a/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/handlers/ViewHandler.java +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/handlers/ViewHandler.java @@ -35,6 +35,9 @@ import org.apache.drill.exec.planner.sql.SchemaUtilities; import org.apache.drill.exec.planner.sql.parser.SqlCreateView; import org.apache.drill.exec.planner.sql.parser.SqlDropView; +import org.apache.drill.exec.security.AccessAuthorizerManager; +import org.apache.drill.exec.security.DdlAccessChecker; +import org.apache.drill.exec.security.spi.AccessType; import org.apache.drill.exec.store.AbstractSchema; import org.apache.drill.exec.work.foreman.ForemanSetupException; import org.apache.calcite.rel.RelNode; @@ -81,6 +84,14 @@ public PhysicalPlan getPlan(SqlNode sqlNode) throws ValidationException, RelConv SchemaUtilities.getSchemaPathAsList(defaultSchema)); final String schemaPath = drillSchema.getFullSchemaName(); + // Ranger DDL authorization: CREATE privilege on the new view. + // OR REPLACE is treated as a single CREATE operation (no extra DROP + // requirement). Checked before the existence check so an unauthorized + // user cannot probe view existence via differing errors. + if (AccessAuthorizerManager.isEnabled(config.getContext().getConfig())) { + DdlAccessChecker.checkDdlAccess(context, drillSchema, newViewName, AccessType.CREATE); + } + // check view creation possibility if(!checkViewCreationPossibility(drillSchema, createView, context)) { return DirectPlan @@ -163,6 +174,13 @@ public PhysicalPlan getPlan(SqlNode sqlNode) throws IOException, ForemanSetupExc final String schemaPath = drillSchema.getFullSchemaName(); + // Ranger DDL authorization: DROP privilege on the target view. + // Checked before the existence check so an unauthorized user cannot + // probe view existence via differing errors. + if (AccessAuthorizerManager.isEnabled(config.getContext().getConfig())) { + DdlAccessChecker.checkDdlAccess(context, drillSchema, viewName, AccessType.DROP); + } + final Table viewToDrop = SqlHandlerUtil.getTableFromSchema(drillSchema, viewName); if (dropView.checkViewExistence()) { if (viewToDrop == null || viewToDrop.getJdbcTableType() != Schema.TableType.VIEW){ diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/security/AccessAuthorizerManager.java b/exec/java-exec/src/main/java/org/apache/drill/exec/security/AccessAuthorizerManager.java new file mode 100644 index 00000000000..fc61c6b6d6b --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/security/AccessAuthorizerManager.java @@ -0,0 +1,189 @@ +/* + * 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; + +import org.apache.drill.common.config.DrillConfig; +import org.apache.drill.exec.security.spi.AccessAuthorizer; +import org.apache.drill.exec.security.spi.AccessAuthorizerFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; + +/** + * Engine-side manager for the {@link AccessAuthorizer} SPI. + *

    + *
  • reads {@code drill.exec.security.authorizer.enabled} — when {@code false} + * (the default) an {@link AllowAllAccessAuthorizer} is used (fail-open);
  • + *
  • flattens the {@code drill.exec.security.authorizer} configuration + * subtree into a plain {@code Map} (engine configuration + * types never leak into the SPI);
  • + *
  • discovers {@link AccessAuthorizerFactory} implementations via + * {@link ServiceLoader} and selects the one whose {@code getName()} + * matches {@code drill.exec.security.authorizer.name} (default + * {@code "ranger"});
  • + *
  • fail-closed: enabled with no matching factory throws at startup + * rather than silently allowing everything.
  • + *
+ * + *

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.

+ */ +public final class AccessAuthorizerManager { + + private static final Logger logger = LoggerFactory.getLogger(AccessAuthorizerManager.class); + + static final String CONFIG_PREFIX = "drill.exec.security.authorizer"; + static final String DEFAULT_FACTORY_NAME = "ranger"; + + private static volatile AccessAuthorizer instance; + + private AccessAuthorizerManager() { + } + + /** + * Returns the singleton {@link AccessAuthorizer} instance, initializing it + * from the given configuration on first call. + * + * @param config the Drill configuration + * @return the authorizer (never {@code null}) + */ + public static AccessAuthorizer getAuthorizer(DrillConfig config) { + if (instance != null) { + return instance; + } + synchronized (AccessAuthorizerManager.class) { + if (instance == null) { + instance = load(config); + } + } + return instance; + } + + /** + * Resets the cached singleton. Package-private; used by tests. + */ + static void reset() { + instance = null; + } + + /** + * Closes the cached authorizer if any, then clears the singleton so a + * future {@link #getAuthorizer(DrillConfig)} initializes a fresh instance. + * + *

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.

+ */ + public static void close() { + synchronized (AccessAuthorizerManager.class) { + AccessAuthorizer authorizer = instance; + // Drop the singleton reference first: even if close() throws, the next + // getAuthorizer() must be able to build a fresh instance. + instance = null; + if (authorizer != null) { + try { + authorizer.close(); + } catch (Exception e) { + logger.warn("Failure while closing access authorizer", e); + } + } + } + } + + private static AccessAuthorizer load(DrillConfig config) { + if (!isEnabled(config)) { + logger.info("Access authorizer disabled (drill.exec.security.authorizer.enabled=false); " + + "using AllowAllAccessAuthorizer (fail-open)"); + return new AllowAllAccessAuthorizer(); + } + + // Read the selection key directly from the config: flattenConfig strips + // the engine-managed keys ("enabled"/"name") from the plugin-visible map. + String name = config.hasPath(CONFIG_PREFIX + ".name") + ? config.getString(CONFIG_PREFIX + ".name") : null; + if (name == null || name.isEmpty()) { + name = DEFAULT_FACTORY_NAME; + } + Map props = flattenConfig(config); + + // Discover factories via the Java SPI. The Ranger factory is shipped on + // the Drillbit classpath (shim jar); Drill core has no compile-time + // reference to any implementation. + for (AccessAuthorizerFactory factory : ServiceLoader.load(AccessAuthorizerFactory.class)) { + if (factory.getName().equals(name)) { + AccessAuthorizer authorizer = factory.createAuthorizer(props); + logger.info("Initialized access authorizer '{}' via {}", name, + factory.getClass().getName()); + return authorizer; + } + logger.debug("Skipping AccessAuthorizerFactory '{}' (does not match configured name '{}')", + factory.getName(), name); + } + throw new RuntimeException("Access authorizer is enabled but no AccessAuthorizerFactory " + + "named '" + name + "' was found on the classpath. Ensure the shim jar registering " + + "META-INF/services/org.apache.drill.exec.security.spi.AccessAuthorizerFactory " + + "is present and initialized correctly."); + } + + /** + * Returns whether the access authorizer is enabled in the configuration + * ({@code drill.exec.security.authorizer.enabled}, default {@code false}). + * + *

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.

+ * + * @param config the Drill configuration + * @return {@code true} only when the enabled flag is explicitly set to true + */ + public static boolean isEnabled(DrillConfig config) { + return config.hasPath(CONFIG_PREFIX + ".enabled") + && config.getBoolean(CONFIG_PREFIX + ".enabled"); + } + + /** + * Flattens the {@code drill.exec.security.authorizer} configuration subtree + * into a plain map of scalar leaf values. The {@code enabled} and {@code name} + * keys are engine-managed selection knobs and are removed from the result. + */ + private static Map flattenConfig(DrillConfig config) { + Map props = new HashMap<>(); + if (config.hasPath(CONFIG_PREFIX)) { + config.getConfig(CONFIG_PREFIX).entrySet().forEach(e -> { + Object value = e.getValue().unwrapped(); + if (value != null && !(value instanceof Map) && !(value instanceof List)) { + props.put(e.getKey(), String.valueOf(value)); + } + }); + } + props.remove("enabled"); + props.remove("name"); + return props; + } +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/security/AllowAllAccessAuthorizer.java b/exec/java-exec/src/main/java/org/apache/drill/exec/security/AllowAllAccessAuthorizer.java new file mode 100644 index 00000000000..7344c11e0ec --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/security/AllowAllAccessAuthorizer.java @@ -0,0 +1,44 @@ +/* + * 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; + +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 java.util.Set; + +/** + * Engine-default implementation that allows all access (mirrors Presto's + * {@code AllowAllAccessControl}). Used when the access authorizer is + * disabled: the engine calls it through the same {@link AccessAuthorizer} + * interface, so mount points need no special-casing for the disabled state. + */ +public class AllowAllAccessAuthorizer implements AccessAuthorizer { + + @Override + public boolean checkTableAccess(UserIdentity user, String dataSource, String schema, + String table, AccessType accessType) { + return true; // fail-open + } + + @Override + public boolean checkColumnAccess(UserIdentity user, String dataSource, String schema, + String table, Set columns, AccessType accessType) { + return true; // fail-open + } +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/security/DdlAccessChecker.java b/exec/java-exec/src/main/java/org/apache/drill/exec/security/DdlAccessChecker.java new file mode 100644 index 00000000000..431f180c2a7 --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/security/DdlAccessChecker.java @@ -0,0 +1,86 @@ +/* + * 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; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.drill.common.config.DrillConfig; +import org.apache.drill.common.exceptions.UserException; +import org.apache.drill.exec.ops.QueryContext; +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.drill.exec.store.AbstractSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * DDL authorization checks for the handlers that create or drop tables and + * views ({@code CREATE TABLE} / CTAS, {@code DROP TABLE}, {@code CREATE VIEW}, + * {@code DROP VIEW}). + * + *

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).

+ */ +public final class DdlAccessChecker { + + private static final Logger logger = LoggerFactory.getLogger(DdlAccessChecker.class); + + private DdlAccessChecker() { + } + + /** + * Checks CREATE/DROP permission on the DDL target object (table or view) + * via the configured {@link AccessAuthorizer} (Ranger by default). Throws + * {@link UserException} permissionError when access is denied. + * + *

Authorization happens before the existence check ("table not found") + * so that an unauthorized user cannot probe object existence through + * differing error messages.

+ * + * @param context query context (session + config) + * @param schema the resolved schema holding the target object + * @param objectName the target table/view name as written by the user + * @param accessType the access type ({@link AccessType#CREATE} or + * {@link AccessType#DROP}) + */ + public static void checkDdlAccess(QueryContext context, AbstractSchema schema, + String objectName, AccessType accessType) { + List qualifiedName = new ArrayList<>(schema.getSchemaPath()); + qualifiedName.add(objectName); + TableAccessResource resource = TableAccessResource.resolve(qualifiedName); + String userName = context.getSession().getCredentials().getUserName(); + DrillConfig drillConfig = context.getConfig(); + AccessAuthorizer authorizer = AccessAuthorizerManager.getAuthorizer(drillConfig); + if (!authorizer.checkTableAccess(UserIdentity.of(userName), resource.getDataSource(), + resource.getSchemaPath(), resource.getTable(), accessType)) { + throw UserException.permissionError() + .message("Access denied: user '%s' lacks %s privilege on %s", + userName, accessType, resource) + .build(logger); + } + } +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/security/TableAccessResource.java b/exec/java-exec/src/main/java/org/apache/drill/exec/security/TableAccessResource.java new file mode 100644 index 00000000000..694a5178eb6 --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/security/TableAccessResource.java @@ -0,0 +1,130 @@ +/* + * 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; + +import org.apache.drill.exec.planner.sql.SchemaUtilities; + +import java.util.List; +import java.util.Objects; + +/** + * The (datasource, schema, table) triple that identifies a table in an access + * check, following the Ranger four-level resource model + * {@code datasource / schema / table / column}. + * + *

Use {@link #resolve(List)} as the single mapping from a Calcite + * table's qualified name to this triple. The mapping MUST be defined exactly + * once: table-level checks (DrillCalciteCatalogReader) and column-level checks + * (ColumnAccessChecker) must address the same resource for the same table, + * otherwise one check may match a Ranger policy while the other does not — + * and since Ranger denies by default, that shows up as an inconsistent + * allow/deny depending on which check fires first.

+ */ +public final class TableAccessResource { + + private final String dataSource; + private final String schemaPath; + private final String table; + + private TableAccessResource(String dataSource, String schemaPath, String table) { + this.dataSource = dataSource; + this.schemaPath = schemaPath; + this.table = table; + } + + /** + * Resolves the access-check resource from a table's qualified name. + * + *

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)}.

+ * + * @param qualifiedName the Calcite-resolved qualified name (never null or + * empty), e.g. {@code [mysql, shf, orders]}, + * {@code [cp, employee.json]} or {@code [orders]} + * @return the resolved (datasource, schema, table) triple + */ + public static TableAccessResource resolve(List qualifiedName) { + Objects.requireNonNull(qualifiedName, "qualifiedName must not be null"); + if (qualifiedName.isEmpty()) { + throw new IllegalArgumentException("qualifiedName must not be empty"); + } + + String table = qualifiedName.get(qualifiedName.size() - 1); + if (qualifiedName.size() > 2) { + // datasource.schema.table OR datasource.subschema.table + String dataSource = qualifiedName.get(0); + String schemaPath = SchemaUtilities.getSchemaPath( + qualifiedName.subList(1, qualifiedName.size() - 1)); + return new TableAccessResource(dataSource, schemaPath, table); + } + if (qualifiedName.size() == 2) { + // datasource.table — backend has no schema; synthesize a default so the + // four-level resource stays complete (policy matching requires a + // non-null schema key when schema is a mandatory resource). + String dataSource = qualifiedName.get(0); + return new TableAccessResource(dataSource, getDefaultSchemaByDataSource(dataSource), table); + } + // Single-element qualified name: the table is registered at the root + // schema. Use the table name itself as the datasource namespace. + return new TableAccessResource(table, getDefaultSchemaByDataSource(table), table); + } + + /** + * Returns the default schema name to use when a table's qualified name does + * not contain an explicit schema segment (i.e. two-segment + * {@code datasource.table} or a single-segment fallback). This keeps the + * four-level resource model complete even for backends that have no native + * schema concept. + * + *

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.

+ * + * @param dataSource the storage plugin / datasource name + * @return a non-null default schema name + */ + private static String getDefaultSchemaByDataSource(String dataSource) { + return switch (dataSource.toLowerCase()) { + case "dfs", "cp" -> "default"; + default -> dataSource; + }; + } + + public String getDataSource() { + return dataSource; + } + + public String getSchemaPath() { + return schemaPath; + } + + public String getTable() { + return table; + } + + @Override + public String toString() { + return dataSource + "." + schemaPath + "." + table; + } +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/server/Drillbit.java b/exec/java-exec/src/main/java/org/apache/drill/exec/server/Drillbit.java index cb78340f7e4..d1c168d3595 100644 --- a/exec/java-exec/src/main/java/org/apache/drill/exec/server/Drillbit.java +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/server/Drillbit.java @@ -34,6 +34,7 @@ import org.apache.drill.exec.exception.DrillbitStartupException; import org.apache.drill.exec.proto.CoordinationProtos.DrillbitEndpoint; import org.apache.drill.exec.proto.CoordinationProtos.DrillbitEndpoint.State; +import org.apache.drill.exec.security.AccessAuthorizerManager; import org.apache.drill.exec.server.DrillbitStateManager.DrillbitState; import org.apache.drill.exec.server.options.OptionDefinition; import org.apache.drill.exec.server.options.OptionValue; @@ -229,6 +230,9 @@ public void run() throws Exception { final DrillbitContext drillbitContext = manager.getContext(); storageRegistry = drillbitContext.getStorage(); storageRegistry.init(); + // Initialize the access authorizer if enabled (fail-fast at startup; the + // cached singleton is later reused by planner mount points) + AccessAuthorizerManager.getAuthorizer(context.getConfig()); drillbitContext.getOptionManager().init(); javaPropertiesToSystemOptions(); manager.getContext().getRemoteFunctionRegistry().init(context.getConfig(), storeProvider, coord); @@ -333,6 +337,12 @@ public synchronized void close() { logger.warn("Failure on close()", e); } + // Release access-authorizer plugin resources (e.g. Ranger policy-refresh + // threads, policy-engine caches) now that all queries have drained and the + // engine services above are closed. Idempotent; safe when authorization is + // disabled or was never initialized. + AccessAuthorizerManager.close(); + logger.info("Shutdown completed ({} ms).", w.elapsed(TimeUnit.MILLISECONDS) ); stateManager.setState(DrillbitState.SHUTDOWN); // Interrupt GracefulShutdownThread since Drillbit close is not called from it. diff --git a/exec/java-exec/src/test/java/org/apache/drill/exec/planner/sql/conversion/ColumnAccessCheckerCorrelatedTest.java b/exec/java-exec/src/test/java/org/apache/drill/exec/planner/sql/conversion/ColumnAccessCheckerCorrelatedTest.java new file mode 100644 index 00000000000..b00c77cb7b6 --- /dev/null +++ b/exec/java-exec/src/test/java/org/apache/drill/exec/planner/sql/conversion/ColumnAccessCheckerCorrelatedTest.java @@ -0,0 +1,479 @@ +/* + * 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 com.google.common.collect.ImmutableList; +import org.apache.calcite.linq4j.tree.Expression; +import org.apache.calcite.plan.ConventionTraitDef; +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.plan.RelOptSchema; +import org.apache.calcite.plan.RelOptTable; +import org.apache.calcite.plan.volcano.VolcanoPlanner; +import org.apache.calcite.rel.RelCollation; +import org.apache.calcite.rel.RelCollationTraitDef; +import org.apache.calcite.rel.RelCollations; +import org.apache.calcite.rel.RelDistribution; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.RelReferentialConstraint; +import org.apache.calcite.rel.core.AggregateCall; +import org.apache.calcite.rel.core.CorrelationId; +import org.apache.calcite.rel.logical.LogicalAggregate; +import org.apache.calcite.rel.logical.LogicalFilter; +import org.apache.calcite.rel.logical.LogicalProject; +import org.apache.calcite.rel.logical.LogicalSort; +import org.apache.calcite.rel.logical.LogicalTableScan; +import org.apache.calcite.rel.metadata.RelMetadataQuery; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rel.type.RelDataTypeField; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexCorrelVariable; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.rex.RexSubQuery; +import org.apache.calcite.schema.ColumnStrategy; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.apache.calcite.sql.type.SqlTypeFactoryImpl; +import org.apache.calcite.sql.type.SqlTypeName; +import org.apache.calcite.util.ImmutableBitSet; +import org.apache.drill.common.config.DrillConfig; +import org.apache.drill.exec.proto.UserBitShared; +import org.apache.drill.exec.rpc.user.UserSession; +import org.apache.drill.test.BaseTest; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Pure JUnit tests for {@link ColumnAccessChecker} focused on correlated + * subquery outer-column references. + * + *

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:

+ *
    + *
  1. Simple correlated EXISTS referencing a column the outer SELECT does + * NOT project — the exact gap flagged by the reviewer at + * ColumnAccessChecker:323 (Doc case 13 masked it with {@code SELECT *}).
  2. + *
  3. Two nested EXISTS subqueries where the innermost does a skip-level + * correlation back to the outermost table — verifies + * {@code putIfAbsent(varName, enclosingScope)} semantics so an + * intermediate enclosing scope does not clobber the outer binding.
  4. + *
+ */ +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> runCheck(RelNode root) throws Exception { + UserBitShared.UserCredentials creds = UserBitShared.UserCredentials.newBuilder() + .setUserName("alice").build(); + UserSession session = Mockito.mock(UserSession.class); + Mockito.when(session.getCredentials()).thenReturn(creds); + + DrillConfig drillConfig = DrillConfig.create(); + RelMetadataQuery mq = cluster.getMetadataQuery(); + ColumnAccessChecker checker = new ColumnAccessChecker(session, drillConfig, mq); + // Bookkeeping map is populated before enforceColumnAccess is called, so + // the default no-op authorizer is sufficient for our purposes. + checker.check(root); + + Field f = ColumnAccessChecker.class.getDeclaredField("tableToReferencedCols"); + f.setAccessible(true); + return (Map>) f.get(checker); + } + + private static void assertRefColumns(RelOptTable table, + Map> state, + Integer... expectedIndices) { + Set actual = state.get(table); + assertNotNull("Expected references for table " + table.getQualifiedName() + + " but none were recorded", actual); + Set expected = new HashSet<>(Arrays.asList(expectedIndices)); + assertEquals("Referenced column indices for " + table.getQualifiedName() + + " differ. expected=" + expected + " actual=" + actual, + expected, actual); + } + + private static void assertRefContains(RelOptTable table, + Map> state, int index) { + Set actual = state.get(table); + assertNotNull("Expected references for " + table.getQualifiedName() + + " but none were recorded", actual); + assertTrue("Expected column index " + index + + " to be recorded for " + table.getQualifiedName() + + ", actual=" + actual, + actual.contains(index)); + } + + private static RelDataType intLiteralRowType(SqlTypeFactoryImpl tf) { + return tf.createStructType( + Collections.singletonList(tf.createSqlType(SqlTypeName.INTEGER)), + Collections.singletonList("$f0")); + } + + // ------------------------------------------------------------------ + // Tests + // ------------------------------------------------------------------ + + /** + * SQL shape: + *
+   *   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> refs = runCheck(outerProject); + + // orders: id (0) from outer SELECT projection + user_id (2) from the + // correlated predicate captured by the new visitFieldAccess handler. + assertRefColumns(ordersTable, refs, COL_ID, COL_USER_ID); + + // customers: the inner filter used user_id (column 2). + assertRefContains(customersTable, refs, COL_USER_ID); + } + + /** + * SQL shape: + *
+   *   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> refs = runCheck(outerProject); + + // orders: id (0) from outer SELECT + outer $cor0.id + skip-level + // $cor0.user_id (column 2). + assertRefColumns(ordersTable, refs, COL_ID, COL_USER_ID); + + // customers: id (0) from middle predicate + user_id (2) from innermost. + assertRefContains(customersTable, refs, COL_ID); + assertRefContains(customersTable, refs, COL_USER_ID); + } + + /** + * SQL shape: + *
+   *   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> refs = runCheck(aggregate); + + // amount (1) from argList + active (3) from filterArg — both must be present + assertRefContains(ordersTable, refs, 1); + assertRefContains(ordersTable, refs, 3); + } + + /** + * SQL shape: + *
+   *   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> refs = runCheck(sort); + + // id (0) from collation + amount (1) from fetch — both must be present + assertRefContains(ordersTable, refs, 0); + assertRefContains(ordersTable, refs, 1); + } + + // ------------------------------------------------------------------ + // Stub RelOptTable: avoids the finicky RelOptTableImpl factory overloads + // while satisfying LogicalTableScan + RelMetadataQuery column-origin + // tracing. Only the methods reached by ColumnAccessChecker + + // LogicalTableScan + MQ.getColumnOrigins are implemented meaningfully. + // ------------------------------------------------------------------ + + private static final class StubRelOptTable implements RelOptTable { + private final ImmutableList qualifiedName; + private final RelDataType rowType; + private final SqlTypeFactoryImpl tf; + + StubRelOptTable(ImmutableList qualifiedName, RelDataType rowType, + SqlTypeFactoryImpl tf) { + this.qualifiedName = qualifiedName; + this.rowType = rowType; + this.tf = tf; + } + + @Override public List getQualifiedName() { return qualifiedName; } + @Override public double getRowCount() { return 100; } + @Override public RelDataType getRowType() { return rowType; } + @Override public RelOptSchema getRelOptSchema() { return null; } + @Override public RelNode toRel(ToRelContext context) { + return LogicalTableScan.create(context.getCluster(), this, Collections.emptyList()); + } + @Override public List getCollationList() { return ImmutableList.of(); } + @Override public RelDistribution getDistribution() { return null; } + @Override public boolean isKey(ImmutableBitSet columns) { return false; } + @Override public List getKeys() { return ImmutableList.of(); } + @Override public List getReferentialConstraints() { + return ImmutableList.of(); + } + @Override public Expression getExpression(Class clazz) { return null; } + @Override public RelOptTable extend(List extendedFields) { + RelDataTypeFactory.Builder b = tf.builder(); + for (RelDataTypeField f : rowType.getFieldList()) { b.add(f.getName(), f.getType()); } + for (RelDataTypeField f : extendedFields) { b.add(f.getName(), f.getType()); } + return new StubRelOptTable(qualifiedName, b.build(), tf); + } + @Override public List getColumnStrategies() { + ImmutableList.Builder b = ImmutableList.builder(); + for (int i = 0; i < rowType.getFieldCount(); i++) { b.add(ColumnStrategy.NULLABLE); } + return b.build(); + } + @Override public C unwrap(Class aClass) { + if (aClass.isInstance(this)) { return aClass.cast(this); } + return null; + } + } +} diff --git a/exec/java-exec/src/test/java/org/apache/drill/exec/security/AccessAuthorizerManagerTest.java b/exec/java-exec/src/test/java/org/apache/drill/exec/security/AccessAuthorizerManagerTest.java new file mode 100644 index 00000000000..05125b4a278 --- /dev/null +++ b/exec/java-exec/src/test/java/org/apache/drill/exec/security/AccessAuthorizerManagerTest.java @@ -0,0 +1,253 @@ +/* + * 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; + +import org.apache.drill.common.config.DrillConfig; +import org.apache.drill.exec.ExecConstants; +import org.apache.drill.exec.security.spi.AccessAuthorizer; +import org.apache.drill.test.BaseTest; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.Properties; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link AccessAuthorizerManager}. + * + *

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.

+ */ +public class AccessAuthorizerManagerTest extends BaseTest { + + /** + * Resets the {@code instance} singleton before/after each test so that the + * double-checked locking in {@link AccessAuthorizerManager#getAuthorizer} + * re-runs the initialization path. {@code reset()} is package-private; + * this test lives in the same package. + */ + @Before + @After + public void resetManagerInstance() { + AccessAuthorizerManager.reset(); + TestAccessAuthorizer.reset(); + } + + // ======================================================================== + // Disabled → AllowAll (fail-open) + // ======================================================================== + + @Test + public void getAuthorizer_returnsAllowAll_whenAuthorizerConfigAbsent() { + // No drill.exec.security.authorizer property at all → treated as disabled + DrillConfig config = DrillConfig.forClient(); + AccessAuthorizer authorizer = AccessAuthorizerManager.getAuthorizer(config); + assertTrue("Expected AllowAllAccessAuthorizer when authorizer config absent", + authorizer instanceof AllowAllAccessAuthorizer); + } + + @Test + public void getAuthorizer_returnsAllowAll_whenEnabledFalse() { + Properties props = new Properties(); + props.setProperty(ExecConstants.AUTHORIZER_ENABLED, "false"); + DrillConfig config = DrillConfig.create(props); + AccessAuthorizer authorizer = AccessAuthorizerManager.getAuthorizer(config); + assertTrue("Expected AllowAllAccessAuthorizer when enabled=false", + authorizer instanceof AllowAllAccessAuthorizer); + } + + @Test + public void getAuthorizer_returnsCachedInstance() { + DrillConfig config = DrillConfig.forClient(); + AccessAuthorizer first = AccessAuthorizerManager.getAuthorizer(config); + AccessAuthorizer second = AccessAuthorizerManager.getAuthorizer(config); + assertSame("Singleton must cache the same instance", first, second); + } + + // ======================================================================== + // Factory discovery via ServiceLoader (test factory, name="test") + // ======================================================================== + + @Test + public void getAuthorizer_selectsFactoryByConfiguredName() { + Properties props = new Properties(); + props.setProperty(ExecConstants.AUTHORIZER_ENABLED, "true"); + props.setProperty(ExecConstants.AUTHORIZER_NAME, "test"); + DrillConfig config = DrillConfig.create(props); + + AccessAuthorizer authorizer = AccessAuthorizerManager.getAuthorizer(config); + assertTrue(authorizer instanceof TestAccessAuthorizer); + } + + /** + * The flattened config passed to the factory must NOT contain the + * engine-managed selection keys ("enabled", "name") — only the + * plugin-specific remainder of the subtree. + */ + @Test + public void getAuthorizer_passesFlattenedConfigWithoutSelectionKeys() { + Properties props = new Properties(); + props.setProperty(ExecConstants.AUTHORIZER_ENABLED, "true"); + props.setProperty(ExecConstants.AUTHORIZER_NAME, "test"); + props.setProperty("drill.exec.security.authorizer.service.name", "myDrillSvc"); + props.setProperty("drill.exec.security.authorizer.custom.key", "customValue"); + DrillConfig config = DrillConfig.create(props); + + AccessAuthorizerManager.getAuthorizer(config); + + assertEquals("myDrillSvc", TestAccessAuthorizer.getLastConfig().get("service.name")); + assertEquals("customValue", TestAccessAuthorizer.getLastConfig().get("custom.key")); + assertFalse("enabled must not leak into factory config", + TestAccessAuthorizer.getLastConfig().containsKey("enabled")); + assertFalse("name must not leak into factory config", + TestAccessAuthorizer.getLastConfig().containsKey("name")); + } + + @Test + public void getAuthorizer_usesDefaultServiceName_whenServiceNameAbsent() { + Properties props = new Properties(); + props.setProperty(ExecConstants.AUTHORIZER_ENABLED, "true"); + props.setProperty(ExecConstants.AUTHORIZER_NAME, "test"); + DrillConfig config = DrillConfig.create(props); + + AccessAuthorizerManager.getAuthorizer(config); + // The test factory applies the same default as the Ranger shim: "drill" + assertEquals("drill", TestAccessAuthorizer.getLastServiceName()); + } + + @Test + public void getAuthorizer_forwardsServiceName_whenConfigured() { + Properties props = new Properties(); + props.setProperty(ExecConstants.AUTHORIZER_ENABLED, "true"); + props.setProperty(ExecConstants.AUTHORIZER_NAME, "test"); + props.setProperty("drill.exec.security.authorizer.service.name", "myDrillSvc"); + DrillConfig config = DrillConfig.create(props); + + AccessAuthorizerManager.getAuthorizer(config); + assertEquals("myDrillSvc", TestAccessAuthorizer.getLastServiceName()); + } + + // ======================================================================== + // Failure modes (fail-closed) + // ======================================================================== + + @Test + public void getAuthorizer_throws_whenNoFactoryMatchesName() { + Properties props = new Properties(); + props.setProperty(ExecConstants.AUTHORIZER_ENABLED, "true"); + props.setProperty(ExecConstants.AUTHORIZER_NAME, "nonexistent"); + DrillConfig config = DrillConfig.create(props); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> AccessAuthorizerManager.getAuthorizer(config)); + assertTrue(ex.getMessage().contains("nonexistent")); + } + + @Test + public void getAuthorizer_throws_whenFactoryCreateFails() { + Properties props = new Properties(); + props.setProperty(ExecConstants.AUTHORIZER_ENABLED, "true"); + props.setProperty(ExecConstants.AUTHORIZER_NAME, "test"); + DrillConfig config = DrillConfig.create(props); + + TestAccessAuthorizer.setShouldThrow(true); + RuntimeException ex = assertThrows(RuntimeException.class, + () -> AccessAuthorizerManager.getAuthorizer(config)); + assertTrue(ex.getMessage().contains("create boom")); + } + + /** + * Sanity check that distinct configs (disabled vs enabled) produce + * non-identical instances after a reset. This guards against the singleton + * cache leaking across tests when @Before/@After reset is misconfigured. + */ + @Test + public void getAuthorizer_reinitializesAfterReset() { + DrillConfig disabledConfig = DrillConfig.forClient(); + AccessAuthorizer first = AccessAuthorizerManager.getAuthorizer(disabledConfig); + assertTrue(first instanceof AllowAllAccessAuthorizer); + + // Reset and ask for an enabled config — must NOT return the cached allow-all + AccessAuthorizerManager.reset(); + TestAccessAuthorizer.reset(); + Properties props = new Properties(); + props.setProperty(ExecConstants.AUTHORIZER_ENABLED, "true"); + props.setProperty(ExecConstants.AUTHORIZER_NAME, "test"); + DrillConfig enabledConfig = DrillConfig.create(props); + + AccessAuthorizer second = AccessAuthorizerManager.getAuthorizer(enabledConfig); + assertNotSame(first, second); + assertTrue(second instanceof TestAccessAuthorizer); + } + + // ======================================================================== + // Shutdown / close lifecycle + // ======================================================================== + + @Test + public void close_withoutInit_isSafe() { + // No authorizer cached yet — close() must be a safe no-op. + AccessAuthorizerManager.close(); + } + + @Test + public void close_closesCachedAuthorizer_andClearsSingleton() { + Properties props = new Properties(); + props.setProperty(ExecConstants.AUTHORIZER_ENABLED, "true"); + props.setProperty(ExecConstants.AUTHORIZER_NAME, "test"); + DrillConfig config = DrillConfig.create(props); + + AccessAuthorizer first = AccessAuthorizerManager.getAuthorizer(config); + assertTrue(first instanceof TestAccessAuthorizer); + + AccessAuthorizerManager.close(); + + assertEquals("close() must be forwarded to the cached authorizer", 1, + TestAccessAuthorizer.getCloseCount()); + AccessAuthorizer second = AccessAuthorizerManager.getAuthorizer(config); + assertNotSame("After close() the singleton must be re-creatable", first, second); + } + + @Test + public void close_isIdempotent_andReinitializesAfterRestart() { + // Simulates a Drillbit shutdown + restart inside the same JVM: after + // close() (even called twice), a new getAuthorizer() must build a fresh, + // working instance — the old one dropped its plugin resources on close. + Properties props = new Properties(); + props.setProperty(ExecConstants.AUTHORIZER_ENABLED, "true"); + props.setProperty(ExecConstants.AUTHORIZER_NAME, "test"); + DrillConfig config = DrillConfig.create(props); + + AccessAuthorizer first = AccessAuthorizerManager.getAuthorizer(config); + AccessAuthorizerManager.close(); + AccessAuthorizerManager.close(); // singleton already cleared: safe no-op + AccessAuthorizer second = AccessAuthorizerManager.getAuthorizer(config); + + assertEquals("close() must be forwarded exactly once", 1, + TestAccessAuthorizer.getCloseCount()); + assertNotSame(first, second); + } +} diff --git a/exec/java-exec/src/test/java/org/apache/drill/exec/security/AllowAllAccessAuthorizerTest.java b/exec/java-exec/src/test/java/org/apache/drill/exec/security/AllowAllAccessAuthorizerTest.java new file mode 100644 index 00000000000..6620bd688aa --- /dev/null +++ b/exec/java-exec/src/test/java/org/apache/drill/exec/security/AllowAllAccessAuthorizerTest.java @@ -0,0 +1,63 @@ +/* + * 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; + +import org.apache.drill.exec.security.spi.AccessType; +import org.apache.drill.exec.security.spi.UserIdentity; +import org.apache.drill.test.BaseTest; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link AllowAllAccessAuthorizer}. + * Verifies the fail-open behavior used when the access authorizer is + * disabled or unavailable. + */ +public class AllowAllAccessAuthorizerTest extends BaseTest { + + private final AllowAllAccessAuthorizer authorizer = new AllowAllAccessAuthorizer(); + + @Test + public void checkTableAccess_returnsTrue() { + assertTrue(authorizer.checkTableAccess( + UserIdentity.of("alice"), "mysql", "shf", "orders", AccessType.SELECT)); + assertTrue(authorizer.checkTableAccess( + UserIdentity.of("bob"), "dfs", "tmp", "foo", AccessType.CREATE)); + } + + @Test + public void checkColumnAccess_returnsTrue() { + Set columns = new HashSet<>(Arrays.asList("id", "amount")); + assertTrue(authorizer.checkColumnAccess( + UserIdentity.of("alice"), "mysql", "shf", "orders", columns, AccessType.SELECT)); + // Empty column set must still pass (fail-open) + assertTrue(authorizer.checkColumnAccess( + UserIdentity.of("alice"), "mysql", "shf", "orders", Collections.emptySet(), AccessType.SELECT)); + } + + @Test + public void checkTableAccess_returnsTrue_withNullArgs() { + // Fail-open contract: even null inputs must not throw; method returns true + assertTrue(authorizer.checkTableAccess(null, null, null, null, null)); + } +} diff --git a/exec/java-exec/src/test/java/org/apache/drill/exec/security/DdlAccessCheckerTest.java b/exec/java-exec/src/test/java/org/apache/drill/exec/security/DdlAccessCheckerTest.java new file mode 100644 index 00000000000..b932c6ccbe9 --- /dev/null +++ b/exec/java-exec/src/test/java/org/apache/drill/exec/security/DdlAccessCheckerTest.java @@ -0,0 +1,150 @@ +/* + * 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; + +import java.util.Properties; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import org.apache.drill.common.config.DrillConfig; +import org.apache.drill.common.exceptions.UserException; +import org.apache.drill.exec.ExecConstants; +import org.apache.drill.exec.ops.QueryContext; +import org.apache.drill.exec.proto.UserBitShared; +import org.apache.drill.exec.rpc.user.UserSession; +import org.apache.drill.exec.security.spi.AccessType; +import org.apache.drill.exec.store.AbstractSchema; +import org.apache.drill.test.BaseTest; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link DdlAccessChecker}. Uses the {@link TestAccessAuthorizer} + * (factory "test", registered via the test META-INF/services file) loaded + * through {@link AccessAuthorizerManager} with a real {@link DrillConfig}; + * {@link QueryContext} and {@link AbstractSchema} are Mockito mocks. + */ +public class DdlAccessCheckerTest extends BaseTest { + + private QueryContext context; + private AbstractSchema schema; + + @Before + @After + public void reset() { + AccessAuthorizerManager.reset(); + TestAccessAuthorizer.reset(); + } + + private void initContext(DrillConfig config, String userName) { + UserSession session = mock(UserSession.class); + when(session.getCredentials()) + .thenReturn(UserBitShared.UserCredentials.newBuilder().setUserName(userName).build()); + context = mock(QueryContext.class); + when(context.getSession()).thenReturn(session); + when(context.getConfig()).thenReturn(config); + schema = mock(AbstractSchema.class); + } + + private DrillConfig enabledConfig() { + Properties props = new Properties(); + props.setProperty(ExecConstants.AUTHORIZER_ENABLED, "true"); + props.setProperty(ExecConstants.AUTHORIZER_NAME, "test"); + return DrillConfig.create(props); + } + + @Test + public void allowsAndMapsResourceWhenNothingDenied() { + initContext(enabledConfig(), "alice"); + when(schema.getSchemaPath()).thenReturn(ImmutableList.of("dfs", "tmp")); + + DdlAccessChecker.checkDdlAccess(context, schema, "t1", AccessType.CREATE); + + TestAccessAuthorizer.TableCheck check = TestAccessAuthorizer.getLastTableCheck(); + assertNotNull(check); + assertEquals("alice", check.user); + assertEquals("dfs", check.dataSource); + assertEquals("tmp", check.schema); + assertEquals("t1", check.table); + assertEquals(AccessType.CREATE, check.accessType); + } + + @Test + public void throwsPermissionErrorWhenCreateDenied() { + initContext(enabledConfig(), "bob"); + when(schema.getSchemaPath()).thenReturn(ImmutableList.of("dfs", "tmp")); + TestAccessAuthorizer.setDeniedAccessTypes(ImmutableSet.of(AccessType.CREATE)); + + UserException e = assertThrows(UserException.class, + () -> DdlAccessChecker.checkDdlAccess(context, schema, "t1", AccessType.CREATE)); + + assertEquals(UserBitShared.DrillPBError.ErrorType.PERMISSION, e.getErrorType()); + assertTrue(e.getOriginalMessage().contains("lacks CREATE privilege")); + assertTrue(e.getOriginalMessage().contains("dfs.tmp.t1")); + } + + @Test + public void denyIsExactPerAccessType() { + initContext(enabledConfig(), "bob"); + when(schema.getSchemaPath()).thenReturn(ImmutableList.of("dfs", "tmp")); + TestAccessAuthorizer.setDeniedAccessTypes(ImmutableSet.of(AccessType.DROP)); + + // DROP is denied, CREATE is not: the CREATE check must pass. + DdlAccessChecker.checkDdlAccess(context, schema, "t1", AccessType.CREATE); + + UserException e = assertThrows(UserException.class, + () -> DdlAccessChecker.checkDdlAccess(context, schema, "t1", AccessType.DROP)); + assertEquals(UserBitShared.DrillPBError.ErrorType.PERMISSION, e.getErrorType()); + assertTrue(e.getOriginalMessage().contains("lacks DROP privilege")); + } + + @Test + public void mapsSchemaLessQualifiedNameToDefaultSchema() { + initContext(enabledConfig(), "alice"); + when(schema.getSchemaPath()).thenReturn(ImmutableList.of("dfs")); + + DdlAccessChecker.checkDdlAccess(context, schema, "t", AccessType.CREATE); + + TestAccessAuthorizer.TableCheck check = TestAccessAuthorizer.getLastTableCheck(); + assertNotNull(check); + assertEquals("dfs", check.dataSource); + assertEquals("default", check.schema); + assertEquals("t", check.table); + } + + @Test + public void allowsAllWhenAuthorizationDisabled() { + initContext(DrillConfig.forClient(), "alice"); + when(schema.getSchemaPath()).thenReturn(ImmutableList.of("dfs", "tmp")); + // deny rules are irrelevant: the manager returns the allow-all authorizer + TestAccessAuthorizer.setDeniedAccessTypes(ImmutableSet.of(AccessType.CREATE)); + + DdlAccessChecker.checkDdlAccess(context, schema, "t1", AccessType.CREATE); + + // The test authorizer was never consulted. + assertNull(TestAccessAuthorizer.getLastTableCheck()); + } +} diff --git a/exec/java-exec/src/test/java/org/apache/drill/exec/security/TableAccessResourceTest.java b/exec/java-exec/src/test/java/org/apache/drill/exec/security/TableAccessResourceTest.java new file mode 100644 index 00000000000..9944de0a935 --- /dev/null +++ b/exec/java-exec/src/test/java/org/apache/drill/exec/security/TableAccessResourceTest.java @@ -0,0 +1,140 @@ +/* + * 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; + +import org.apache.drill.test.BaseTest; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +/** + * Unit tests for {@link TableAccessResource#resolve(List)}: the single + * mapping from a Calcite qualified name to the (datasource, schema, table) + * access-check resource. Both table-level (DrillCalciteCatalogReader) and + * column-level (ColumnAccessChecker) checks go through it, so these tests + * pin the exact resource each check will address. + */ +public class TableAccessResourceTest extends BaseTest { + + // ======================================================================== + // Three or more segments: datasource.schema.table + // ======================================================================== + + @Test + public void resolve_threeSegments_splitsDataSourceSchemaTable() { + TableAccessResource r = TableAccessResource.resolve(Arrays.asList("mysql", "shf", "orders")); + assertEquals("mysql", r.getDataSource()); + assertEquals("shf", r.getSchemaPath()); + assertEquals("orders", r.getTable()); + } + + @Test + public void resolve_nestedSchema_joinsMiddleSegments() { + // Nested schema path: [dfs, tmp, sub, orders] -> schema "tmp.sub" + TableAccessResource r = TableAccessResource.resolve(Arrays.asList("dfs", "tmp", "sub", "orders")); + assertEquals("dfs", r.getDataSource()); + assertEquals("tmp.sub", r.getSchemaPath()); + assertEquals("orders", r.getTable()); + } + + @Test + public void resolve_schema_excludesDataSourcePrefix() { + // The schema MUST NOT include the datasource prefix, otherwise policy + // matching fails (policy has schema=shf but request would send mysql.shf). + TableAccessResource r = TableAccessResource.resolve(Arrays.asList("mysql", "shf", "orders")); + assertEquals("shf", r.getSchemaPath()); + } + + // ======================================================================== + // Two segments: datasource.table (backend without schema concept) + // ======================================================================== + + @Test + public void resolve_twoSegments_synthesizesDefaultSchema_forDfs() { + TableAccessResource r = TableAccessResource.resolve(Arrays.asList("dfs", "orders")); + assertEquals("dfs", r.getDataSource()); + assertEquals("default", r.getSchemaPath()); + assertEquals("orders", r.getTable()); + } + + @Test + public void resolve_twoSegments_synthesizesDefaultSchema_forCp() { + TableAccessResource r = TableAccessResource.resolve(Arrays.asList("cp", "employee.json")); + assertEquals("cp", r.getDataSource()); + assertEquals("default", r.getSchemaPath()); + } + + @Test + public void resolve_twoSegments_defaultSchemaIsCaseInsensitive() { + // "DFS" lowercases to "dfs" which hits the explicit case -> "default" + assertEquals("default", + TableAccessResource.resolve(Arrays.asList("DFS", "orders")).getSchemaPath()); + } + + @Test + public void resolve_twoSegments_unknownPlugin_usesPluginNameAsSchema() { + // No explicit default-schema mapping for "mysql": the plugin name itself + // becomes the default schema namespace (preserving case). + TableAccessResource r = TableAccessResource.resolve(Arrays.asList("mysql", "orders")); + assertEquals("mysql", r.getDataSource()); + assertEquals("mysql", r.getSchemaPath()); + assertEquals("MySql", + TableAccessResource.resolve(Arrays.asList("MySql", "orders")).getSchemaPath()); + } + + // ======================================================================== + // Single segment: root-level table + // ======================================================================== + + @Test + public void resolve_singleSegment_usesTableAsDataSourceNamespace() { + TableAccessResource r = TableAccessResource.resolve(Collections.singletonList("orders")); + assertEquals("orders", r.getDataSource()); + assertEquals("orders", r.getSchemaPath()); + assertEquals("orders", r.getTable()); + } + + // ======================================================================== + // Invalid input + // ======================================================================== + + @Test + public void resolve_nullQualifiedName_throws() { + assertThrows(NullPointerException.class, () -> TableAccessResource.resolve(null)); + } + + @Test + public void resolve_emptyQualifiedName_throws() { + List empty = Collections.emptyList(); + assertThrows(IllegalArgumentException.class, () -> TableAccessResource.resolve(empty)); + } + + // ======================================================================== + // toString: used in permission-error messages + // ======================================================================== + + @Test + public void toString_rendersDottedPath() { + TableAccessResource r = TableAccessResource.resolve(Arrays.asList("mysql", "shf", "orders")); + assertEquals("mysql.shf.orders", r.toString()); + } +} diff --git a/exec/java-exec/src/test/java/org/apache/drill/exec/security/TestAccessAuthorizer.java b/exec/java-exec/src/test/java/org/apache/drill/exec/security/TestAccessAuthorizer.java new file mode 100644 index 00000000000..51459737ab0 --- /dev/null +++ b/exec/java-exec/src/test/java/org/apache/drill/exec/security/TestAccessAuthorizer.java @@ -0,0 +1,161 @@ +/* + * 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; + +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 java.util.Collections; +import java.util.Map; +import java.util.Set; + +/** + * Test-only {@link AccessAuthorizer} created by + * {@link TestAccessAuthorizerFactory}. Records the flattened config map it + * was created with so tests can assert the manager's config handling. + * + *

Also provides test knobs for authorization checks: + *

    + *
  • {@code setDeniedAccessTypes(...)} — denies exactly the given access + * types (default: deny nothing, i.e. allow all);
  • + *
  • {@code getLastTableCheck()} / {@code getLastColumnCheck()} — records + * the most recent check arguments so tests can assert resource mapping + * (datasource/schema/table and the requesting user).
  • + *
+ * All state is static so a single instance created via the manager serves a + * whole embedded Drillbit; call {@link #reset()} between tests.

+ */ +public class TestAccessAuthorizer implements AccessAuthorizer { + + /** Arguments of the most recent checkTableAccess call. */ + public static class TableCheck { + public final String user; + public final String dataSource; + public final String schema; + public final String table; + public final AccessType accessType; + + TableCheck(String user, String dataSource, String schema, String table, AccessType accessType) { + this.user = user; + this.dataSource = dataSource; + this.schema = schema; + this.table = table; + this.accessType = accessType; + } + } + + /** Arguments of the most recent checkColumnAccess call. */ + public static class ColumnCheck { + public final String user; + public final String dataSource; + public final String schema; + public final String table; + public final Set columns; + public final AccessType accessType; + + ColumnCheck(String user, String dataSource, String schema, String table, + Set columns, AccessType accessType) { + this.user = user; + this.dataSource = dataSource; + this.schema = schema; + this.table = table; + this.columns = columns; + this.accessType = accessType; + } + } + + private static volatile boolean shouldThrow; + private static volatile String lastServiceName; + private static volatile Map lastConfig; + private static volatile Set deniedAccessTypes = Collections.emptySet(); + private static volatile TableCheck lastTableCheck; + private static volatile ColumnCheck lastColumnCheck; + private static volatile int closeCount; + + public static void reset() { + shouldThrow = false; + lastServiceName = null; + lastConfig = null; + deniedAccessTypes = Collections.emptySet(); + lastTableCheck = null; + lastColumnCheck = null; + closeCount = 0; + } + + public static String getLastServiceName() { + return lastServiceName; + } + + public static Map getLastConfig() { + return lastConfig; + } + + public static void setShouldThrow(boolean value) { + shouldThrow = value; + } + + /** + * Sets the access types that will be denied. Any access type not in the set + * is allowed. Default (after {@link #reset()}) is an empty set: allow all. + */ + public static void setDeniedAccessTypes(Set types) { + deniedAccessTypes = types == null ? Collections.emptySet() : types; + } + + /** Returns the most recent checkTableAccess call, or {@code null} if none. */ + public static TableCheck getLastTableCheck() { + return lastTableCheck; + } + + /** Returns the most recent checkColumnAccess call, or {@code null} if none. */ + public static ColumnCheck getLastColumnCheck() { + return lastColumnCheck; + } + + /** Returns how many times close() has been invoked (shutdown lifecycle). */ + public static int getCloseCount() { + return closeCount; + } + + TestAccessAuthorizer(Map config) { + lastConfig = config; + lastServiceName = config.getOrDefault("service.name", "drill"); + if (shouldThrow) { + throw new RuntimeException("create boom"); + } + } + + @Override + public boolean checkTableAccess(UserIdentity user, String dataSource, String schema, + String table, AccessType accessType) { + lastTableCheck = new TableCheck(user.getUser(), dataSource, schema, table, accessType); + return !deniedAccessTypes.contains(accessType); + } + + @Override + public boolean checkColumnAccess(UserIdentity user, String dataSource, String schema, + String table, Set columns, AccessType accessType) { + lastColumnCheck = new ColumnCheck(user.getUser(), dataSource, schema, table, columns, accessType); + return !deniedAccessTypes.contains(accessType); + } + + @Override + public void close() { + closeCount++; + } +} diff --git a/exec/java-exec/src/test/java/org/apache/drill/exec/security/TestAccessAuthorizerFactory.java b/exec/java-exec/src/test/java/org/apache/drill/exec/security/TestAccessAuthorizerFactory.java new file mode 100644 index 00000000000..47b222965a0 --- /dev/null +++ b/exec/java-exec/src/test/java/org/apache/drill/exec/security/TestAccessAuthorizerFactory.java @@ -0,0 +1,46 @@ +/* + * 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; + +import org.apache.drill.exec.security.spi.AccessAuthorizer; +import org.apache.drill.exec.security.spi.AccessAuthorizerFactory; + +import java.util.Map; + +/** + * Test-only {@link AccessAuthorizerFactory} registered via the test + * {@code META-INF/services} file. Selected by configuring + * {@code drill.exec.security.authorizer.name=test}, which lets + * {@link AccessAuthorizerManagerTest} exercise the manager's ServiceLoader + * discovery and config-flattening logic WITHOUT touching the production + * Ranger shim (whose {@code RangerPluginClassLoader} needs the assembled + * distribution directories). + */ +public class TestAccessAuthorizerFactory implements AccessAuthorizerFactory { + + public static final String NAME = "test"; + + @Override + public String getName() { + return NAME; + } + + @Override + public AccessAuthorizer createAuthorizer(Map config) { + return new TestAccessAuthorizer(config); + } +} diff --git a/exec/java-exec/src/test/java/org/apache/drill/exec/sql/TestWithClauseRangerAuthz.java b/exec/java-exec/src/test/java/org/apache/drill/exec/sql/TestWithClauseRangerAuthz.java new file mode 100644 index 00000000000..a2633361ddb --- /dev/null +++ b/exec/java-exec/src/test/java/org/apache/drill/exec/sql/TestWithClauseRangerAuthz.java @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor 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.sql; + +import org.apache.drill.categories.SqlTest; +import org.apache.drill.exec.proto.UserBitShared; +import org.apache.drill.test.BaseTestQuery; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +/** + * CTE (Common Table Expression, {@code WITH ... AS (...)}) authorization tests + * under Ranger column-level access control. + * + *

Prerequisites: These tests require a live Drill cluster with: + *

    + *
  • Ranger authorization enabled ({@code drill.exec.security.authorizer.enabled=true})
  • + *
  • A MySQL storage plugin named {@code mysql} with schema {@code shf}
  • + *
  • Tables {@code mysql.shf.orders} (columns: id, amount, user_id, order_date) + * and {@code mysql.shf.users} (all columns)
  • + *
  • Ranger Policy A: {@code users} table, column {@code *}, SELECT
  • + *
  • Ranger Policy B: {@code orders} table, columns {@code id, amount}, SELECT
  • + *
+ * See {@code docs/dev/RangerAuthorization.md} section 4.1 for the sample policies.

+ * + *

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 @@ datanucleus.autoCreateTables true + + io.compression.codecs + org.apache.hadoop.io.compress.DefaultCodec,org.apache.hadoop.io.compress.GzipCodec,org.apache.hadoop.io.compress.BZip2Codec,org.apache.hadoop.io.compress.SnappyCodec,org.apache.hadoop.io.compress.Lz4Codec,org.apache.hadoop.io.compress.ZStandardCodec + Explicit codec list: skips ServiceLoader discovery, which + fails on Windows when the BrotliCodec native library (brotli.dll) is + not available. BrotliCodec's constructor eagerly loads the native + library and throws ServiceConfigurationError otherwise. + \ No newline at end of file diff --git a/exec/pom.xml b/exec/pom.xml index 29b0ccf87e7..68f729abfd3 100644 --- a/exec/pom.xml +++ b/exec/pom.xml @@ -50,6 +50,7 @@ memory rpc vector + security-spi java-exec jdbc diff --git a/exec/security-spi/pom.xml b/exec/security-spi/pom.xml new file mode 100644 index 00000000000..c07a6e7dd84 --- /dev/null +++ b/exec/security-spi/pom.xml @@ -0,0 +1,42 @@ + + + + 4.0.0 + + exec-parent + org.apache.drill.exec + 1.23.0-SNAPSHOT + + + org.apache.drill + drill-security-spi + Drill : Exec : Security SPI + + Access authorization SPI for Drill (AccessAuthorizer, + AccessAuthorizerFactory, AccessType, UserIdentity). Depends only on + the JDK so external authorization plugins (e.g. Apache Ranger) can + implement the SPI without a Drill engine dependency — analogous to + Presto's presto-spi module. + + diff --git a/exec/security-spi/src/main/java/org/apache/drill/exec/security/spi/AccessAuthorizer.java b/exec/security-spi/src/main/java/org/apache/drill/exec/security/spi/AccessAuthorizer.java new file mode 100644 index 00000000000..1b82fb457e8 --- /dev/null +++ b/exec/security-spi/src/main/java/org/apache/drill/exec/security/spi/AccessAuthorizer.java @@ -0,0 +1,96 @@ +/* + * 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.Set; + +/** + * Drill access authorization SPI interface. + * + *

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, Set columns, AccessType accessType); + + /** + * Releases any resources held by this authorizer (background policy-refresh + * threads, caches, open connections). Invoked by the engine when the + * Drillbit shuts down; after this call the instance must be considered + * unusable, and the engine drops its cached reference so a subsequent + * Drillbit start initializes a fresh instance. + * + *

Default 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(Map config); +} diff --git a/exec/security-spi/src/main/java/org/apache/drill/exec/security/spi/AccessType.java b/exec/security-spi/src/main/java/org/apache/drill/exec/security/spi/AccessType.java new file mode 100644 index 00000000000..9962793c8fe --- /dev/null +++ b/exec/security-spi/src/main/java/org/apache/drill/exec/security/spi/AccessType.java @@ -0,0 +1,41 @@ +/* + * 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; + +/** + * Access type for {@link AccessAuthorizer#checkTableAccess} and + * {@link AccessAuthorizer#checkColumnAccess} — mirrors Presto's + * {@code io.prestosql.spi.security.Privilege} enum. + * + *

The 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 groups; + private final Optional principal; + + private UserIdentity(String user, Set groups, + Optional principal) { + this.user = Objects.requireNonNull(user, "user is null"); + this.groups = groups == null ? Collections.emptySet() : Collections.unmodifiableSet(groups); + this.principal = principal == null ? Optional.empty() : principal; + } + + public String getUser() { + return user; + } + + public Set getGroups() { + return groups; + } + + public Optional getPrincipal() { + return principal; + } + + // ---------- Builder ---------- + public static Builder builder() { + return new Builder(); + } + + /** + * Convenience factory for a user identity with only a user name + * (no groups, no principal). Used by engine mount points that only + * have the authenticated user name available. + */ + public static UserIdentity of(String user) { + return builder().setUser(user).build(); + } + + public static class Builder { + private String user; + private Set groups; + private Optional principal = Optional.empty(); + + public Builder setUser(String user) { + this.user = user; + return this; + } + + public Builder setGroups(Set groups) { + this.groups = groups; + return this; + } + + public Builder setPrincipal(Optional principal) { + this.principal = principal; + return this; + } + + public UserIdentity build() { + return new UserIdentity(user, groups, principal); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UserIdentity that = (UserIdentity) o; + return user.equals(that.user) && + groups.equals(that.groups) && + principal.equals(that.principal); + } + + @Override + public int hashCode() { + return Objects.hash(user, groups, principal); + } + + @Override + public String toString() { + return "UserIdentity{user='" + user + "', groups=" + groups + "}"; + } +} diff --git a/pom.xml b/pom.xml index 1a107272670..5013bd13cb1 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,44 @@ 2.2 2.6.1-hadoop3 4.0.3 + + 2.9.0 + + 2.35 + + 2.6.1 + + 2.1.6 + + 1.3.5 + + 1.0 + + 1.0.3 + ranger + + auth + +