From 5c46a5df0ab1479701cd8738d451ac7934f529fd Mon Sep 17 00:00:00 2001 From: shfshihuafeng Date: Wed, 15 Jul 2026 11:03:13 +0800 Subject: [PATCH] DRILL-8548: Integrate Apache Ranger authorization for Drill --- distribution/pom.xml | 17 + distribution/src/assemble/component.xml | 21 + .../src/main/resources/drill-config.sh | 5 + .../resources/ranger/ranger-drill-audit.xml | 49 ++ .../ranger/ranger-drill-security.xml | 51 ++ .../ranger/ranger-servicedef-drill.json | 163 +++++++ docs/dev/DevDocs.md | 7 + docs/dev/RangerAuthorization.md | 222 +++++++++ drill-ranger/drill-ranger-plugin/pom.xml | 204 ++++++++ .../drill/authorizer/DrillAccessControl.java | 329 +++++++++++++ .../drill/authorizer/DrillAuthorizer.java | 185 +++++++ .../authorizer/RangerBaseAuthorizer.java | 98 ++++ .../drill/authorizer/RangerDrillPlugin.java | 32 ++ .../drill/authorizer/ValidationLevel.java | 32 ++ .../drill/resource/DrillAccessResource.java | 94 ++++ .../drill/resource/DrillAccessType.java | 21 + .../resource/DrillRangerAccessRequest.java | 149 ++++++ .../drill/resource/DrillResource.java | 76 +++ .../authorizer/DrillAccessControlTest.java | 261 ++++++++++ .../drill/authorizer/DrillAuthorizerTest.java | 229 +++++++++ .../resource/DrillAccessResourceTest.java | 104 ++++ .../DrillRangerAccessRequestTest.java | 156 ++++++ drill-ranger/drill-ranger-service/pom.xml | 139 ++++++ .../services/drill/RangerServiceDrill.java | 457 ++++++++++++++++++ .../drill/RangerServiceDrillTest.java | 344 +++++++++++++ drill-ranger/pom.xml | 74 +++ exec/java-exec/pom.xml | 6 + .../org/apache/drill/exec/ExecConstants.java | 5 + .../sql/conversion/ColumnAccessChecker.java | 351 ++++++++++++++ .../conversion/DrillCalciteCatalogReader.java | 96 +++- .../planner/sql/conversion/SqlConverter.java | 6 + .../security/ranger/AccessAuthorizer.java | 69 +++ .../ranger/AccessAuthorizerFactory.java | 92 ++++ .../security/ranger/NoOpAccessAuthorizer.java | 47 ++ .../ranger/RangerAccessAuthorizer.java | 69 +++ .../apache/drill/exec/server/Drillbit.java | 3 + .../src/main/resources/drill-module.conf | 5 + .../DrillCalciteCatalogReaderTest.java | 70 +++ .../ranger/AccessAuthorizerFactoryTest.java | 198 ++++++++ .../ranger/NoOpAccessAuthorizerTest.java | 76 +++ .../ranger/RangerAccessAuthorizerTest.java | 162 +++++++ pom.xml | 1 + 42 files changed, 4771 insertions(+), 4 deletions(-) create mode 100644 distribution/src/main/resources/ranger/ranger-drill-audit.xml create mode 100644 distribution/src/main/resources/ranger/ranger-drill-security.xml create mode 100644 distribution/src/main/resources/ranger/ranger-servicedef-drill.json create mode 100644 docs/dev/RangerAuthorization.md create mode 100644 drill-ranger/drill-ranger-plugin/pom.xml create mode 100644 drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControl.java create mode 100644 drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizer.java create mode 100644 drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerBaseAuthorizer.java create mode 100644 drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerDrillPlugin.java create mode 100644 drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/ValidationLevel.java create mode 100644 drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessResource.java create mode 100644 drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessType.java create mode 100644 drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequest.java create mode 100644 drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillResource.java create mode 100644 drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControlTest.java create mode 100644 drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizerTest.java create mode 100644 drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/resource/DrillAccessResourceTest.java create mode 100644 drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequestTest.java create mode 100644 drill-ranger/drill-ranger-service/pom.xml create mode 100644 drill-ranger/drill-ranger-service/src/main/java/org/apache/ranger/services/drill/RangerServiceDrill.java create mode 100644 drill-ranger/drill-ranger-service/src/test/java/org/apache/ranger/services/drill/RangerServiceDrillTest.java create mode 100644 drill-ranger/pom.xml create mode 100644 exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/ColumnAccessChecker.java create mode 100644 exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/AccessAuthorizer.java create mode 100644 exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/AccessAuthorizerFactory.java create mode 100644 exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/NoOpAccessAuthorizer.java create mode 100644 exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizer.java create mode 100644 exec/java-exec/src/test/java/org/apache/drill/exec/planner/sql/conversion/DrillCalciteCatalogReaderTest.java create mode 100644 exec/java-exec/src/test/java/org/apache/drill/exec/security/ranger/AccessAuthorizerFactoryTest.java create mode 100644 exec/java-exec/src/test/java/org/apache/drill/exec/security/ranger/NoOpAccessAuthorizerTest.java create mode 100644 exec/java-exec/src/test/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizerTest.java diff --git a/distribution/pom.xml b/distribution/pom.xml index 10e27292638..bf5d805f4a3 100644 --- a/distribution/pom.xml +++ b/distribution/pom.xml @@ -73,6 +73,23 @@ drill-common ${project.version} + + org.apache.drill + drill-ranger-plugin + ${project.version} + + + + org.apache.drill + ranger-drill-service + ${project.version} + jar + org.apache.drill drill-yarn diff --git a/distribution/src/assemble/component.xml b/distribution/src/assemble/component.xml index 71974cdd127..c1630f8ddb1 100644 --- a/distribution/src/assemble/component.xml +++ b/distribution/src/assemble/component.xml @@ -65,6 +65,7 @@ org.apache.drill:drill-common:jar org.apache.drill:drill-logical:jar org.apache.drill:drill-protocol:jar + org.apache.drill:drill-ranger-plugin: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 +99,21 @@ false + + + + org.apache.drill:ranger-drill-service:jar + + jars/ranger-service + false + + jars/classb false @@ -220,6 +236,11 @@ ${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/ranger/ranger-drill-audit.xml b/distribution/src/main/resources/ranger/ranger-drill-audit.xml new file mode 100644 index 00000000000..68fdaa6140d --- /dev/null +++ b/distribution/src/main/resources/ranger/ranger-drill-audit.xml @@ -0,0 +1,49 @@ + + + + + + + xasecure.audit.is.audit.to.solr + false + + + xasecure.audit.solr.url + http://ranger-admin-host:6083/solr/ranger_audits + + + + xasecure.audit.is.audit.to.hdfs + false + + + xasecure.audit.hdfs.config.directory + hdfs://namenode:8020/ranger/audit + + + xasecure.audit.hdfs.config.file + /etc/hadoop/conf/core-site.xml + + + + + xasecure.audit.is.audit.to.log4j + true + + 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..18ac4290ae2 --- /dev/null +++ b/distribution/src/main/resources/ranger/ranger-drill-security.xml @@ -0,0 +1,51 @@ + + + + + + + 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.RangerAdminRESTClient + + + + + ranger.plugin.drill.policy.pollIntervalMs + 30000 + + + + + ranger.plugin.drill.policy.cache.dir + /tmp/ranger/drill/policy + + diff --git a/distribution/src/main/resources/ranger/ranger-servicedef-drill.json b/distribution/src/main/resources/ranger/ranger-servicedef-drill.json new file mode 100644 index 00000000000..eab96c3eb90 --- /dev/null +++ b/distribution/src/main/resources/ranger/ranger-servicedef-drill.json @@ -0,0 +1,163 @@ +{ + "name": "drill", + "label": "Apache Drill", + "description": "Apache Drill distributed SQL query engine", + "guid": "d8b1c0a2-7e3f-4b2a-9c1d-0a1b2c3d4e5f", + "implClass": "org.apache.ranger.services.drill.RangerServiceDrill", + "version": 1, + "resources": [ + { + "itemId": 1, + "name": "datasource", + "label": "Data Source", + "description": "Drill storage plugin name (e.g. dfs, hive, mongo)", + "level": 10, + "parent": "", + "mandatory": true, + "lookupSupported": true, + "recursiveSupported": false, + "excludesSupported": false, + "matcher": "org.apache.ranger.plugin.resourcematcher.RangerDefaultResourceMatcher", + "matcherOptions": { + "wildCard": "false", + "ignoreCase": "false" + }, + "validationRegEx": "", + "uiPageSize": "", + "accessTypeRestrictions": [] + }, + { + "itemId": 2, + "name": "schema", + "label": "Schema", + "description": "Schema path within the data source (e.g. tmp, default)", + "level": 20, + "parent": "datasource", + "mandatory": true, + "lookupSupported": true, + "recursiveSupported": true, + "excludesSupported": true, + "matcher": "org.apache.ranger.plugin.resourcematcher.RangerDefaultResourceMatcher", + "matcherOptions": { + "wildCard": "true", + "ignoreCase": "false" + }, + "validationRegEx": "", + "uiPageSize": "", + "accessTypeRestrictions": [] + }, + { + "itemId": 3, + "name": "table", + "label": "Table", + "description": "Table name within the schema", + "level": 30, + "parent": "schema", + "mandatory": true, + "lookupSupported": true, + "recursiveSupported": true, + "excludesSupported": true, + "matcher": "org.apache.ranger.plugin.resourcematcher.RangerDefaultResourceMatcher", + "matcherOptions": { + "wildCard": "true", + "ignoreCase": "false" + }, + "validationRegEx": "", + "uiPageSize": "", + "accessTypeRestrictions": [] + }, + { + "itemId": 4, + "name": "column", + "label": "Column", + "description": "Column name within the table", + "level": 40, + "parent": "table", + "mandatory": false, + "lookupSupported": true, + "recursiveSupported": true, + "excludesSupported": true, + "matcher": "org.apache.ranger.plugin.resourcematcher.RangerDefaultResourceMatcher", + "matcherOptions": { + "wildCard": "true", + "ignoreCase": "false" + }, + "validationRegEx": "", + "uiPageSize": "", + "accessTypeRestrictions": [] + } + ], + "accessTypes": [ + { + "itemId": 1, + "name": "SELECT", + "label": "Select", + "impliedGrants": ["SHOW", "USE"] + }, + { + "itemId": 2, + "name": "CREATE", + "label": "Create" + }, + { + "itemId": 3, + "name": "INSERT", + "label": "Insert" + }, + { + "itemId": 4, + "name": "DROP", + "label": "Drop" + }, + { + "itemId": 5, + "name": "SHOW", + "label": "Show", + "impliedGrants": [] + }, + { + "itemId": 6, + "name": "USE", + "label": "Use", + "impliedGrants": [] + }, + { + "itemId": 7, + "name": "DELETE", + "label": "Delete" + } + ], + "serviceConfigOptions": [ + { + "itemId": 1, + "name": "username", + "label": "Username", + "description": "Drill user name for policy lookup connection", + "type": "string", + "mandatory": true, + "defaultValue": "", + "validationRegEx": "" + }, + { + "itemId": 2, + "name": "password", + "label": "Password", + "description": "Drill user password", + "type": "password", + "mandatory": false, + "defaultValue": "" + }, + { + "itemId": 3, + "name": "drill.connection.url", + "label": "Drill REST URL", + "description": "Drill REST API endpoint for resource lookup (e.g. http://drillbit-host:8047)", + "type": "string", + "mandatory": true, + "defaultValue": "http://localhost:8047" + } + ], + "policyConditions": [], + "contextEnrichers": [], + "enums": [] +} 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..5847a39ba7f --- /dev/null +++ b/docs/dev/RangerAuthorization.md @@ -0,0 +1,222 @@ +# 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) | | (static facade) | | +| +-------------------------------+ +-----------------------+ | +| | | +| +----------------------------------------------------------------+ +| | drill-ranger-plugin (JDK 11, deployed to jars/3rdparty/) | +| | DrillAuthorizer DrillAccessResource DrillRangerAccessRequest| +| | RangerDrillPlugin RangerBaseAuthorizer | +| +-----------------------------+--------------------------------+ +| | +| v +| +----------------------------------------------------------------+ +| | drill-ranger-service (JDK 8, deployed to Ranger Admin) | +| | RangerServiceDrill (validateConfig, lookupResource) | +| | Uses Drill REST API (POST /query.json) — NOT JDBC | +| +----------------------------------------------------------------+ +``` + +### 1.1 Modules + +| Module | JDK | Deployed To | Responsibility | +|--------|-----|------------|----------------| +| `drill-ranger-plugin` | 11 | Drillbit `jars/3rdparty/` | Drillbit-side authorization: wraps `RangerBasePlugin`, exposes `DrillAccessControl` facade | +| `drill-ranger-service` | 8 | Ranger Admin `WEB-INF/classes/lib/` | Ranger Admin-side service plugin: `validateConfig` and `lookupResource` via Drill REST API | +| `exec/java-exec` | 11 | Drillbit | Integration hooks: `AccessAuthorizerFactory`, `ColumnAccessChecker`, `DrillCalciteCatalogReader` | + +### 1.2 Why Two Submodules with Different JDK? + +Ranger Admin runs on JDK 8. If `drill-ranger-service` were compiled with JDK 11 +bytecode (class major version 55), Ranger Admin would throw +`UnsupportedClassVersionError`. Conversely, `drill-ranger-plugin` runs inside +Drillbit which requires JDK 11. The split ensures each jar matches its host +runtime. + +`drill-ranger-service` uses the Drill REST API (`POST /query.json`) instead of +JDBC precisely to avoid pulling in `drill-jdbc` (JDK 11 bytecode) into the +Ranger Admin classpath. + +## 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`. + +## 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` → `NoOpAccessAuthorizer` (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 + +Register the Drill service using `ranger-servicedef-drill.json` (located in +`distribution/src/main/resources/ranger/`). Configure: + +- `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, three deployment actions are required to make +Ranger Admin recognize Drill as an authorization provider. + +#### Step 1: Upload `drill-ranger-service` jar to Ranger Admin + +Copy the `drill-ranger-service` jar (the thin jar, NOT the +`jar-with-dependencies` classifier) into Ranger Admin's per-service plugin +directory. Create the `drill` subdirectory if it does not exist. + +```bash +# On the Ranger Admin host +RANGER_ADMIN_HOME=/data/ranger-2.8.1-SNAPSHOT-admin +TARGET_DIR=$RANGER_ADMIN_HOME/ews/webapp/WEB-INF/classes/ranger-plugins/drill + +mkdir -p "$TARGET_DIR" +cp drill-ranger-service-X.XX.X-SNAPSHOT.jar "$TARGET_DIR/" +``` + +#### Step 2: 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`) | + +#### Step 3: Register the Drill service definition in Ranger Admin + +Upload `ranger-servicedef-drill.json` to Ranger Admin's REST API. After this +call succeeds, the "drill" service type appears in Ranger Admin's "Service +Manager" → "+" dropdown, and you can create a Drill service instance and +author policies. + +```bash +curl -u user:password -X POST \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + http://ranger-admin-host:port/service/plugins/definitions \ + -d@distribution/src/main/resources/ranger/ranger-servicedef-drill.json +``` +### 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.) | +| `ranger-servicedef-drill.json` | `distribution/src/main/resources/ranger/` | Service definition: resources, access types, config validation | + +## 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) | + +Under these policies, the `orders.user_id` column is NOT authorized. The +`users` table allows all columns via `*`. + +### 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.** | diff --git a/drill-ranger/drill-ranger-plugin/pom.xml b/drill-ranger/drill-ranger-plugin/pom.xml new file mode 100644 index 00000000000..10f4bc7a3f3 --- /dev/null +++ b/drill-ranger/drill-ranger-plugin/pom.xml @@ -0,0 +1,204 @@ + + + + 4.0.0 + + + org.apache.drill + drill-ranger-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. Does NOT connect + to Drill and does NOT depend on drill-jdbc. + + + + org.apache.ranger + ranger-plugins-common + ${ranger.version} + + + org.apache.ranger + ranger-plugins-cred + + + org.apache.ranger + ranger-plugins-audit + + + 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 + * + + + + + + + com.sun.jersey + jersey-client + ${jersey.ranger.version} + + + javax.ws.rs + jsr311-api + + + + + com.sun.jersey + jersey-core + ${jersey.ranger.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/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControl.java b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControl.java new file mode 100644 index 00000000000..4f016e85a30 --- /dev/null +++ b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControl.java @@ -0,0 +1,329 @@ +/* + * 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.hadoop.security.UserGroupInformation; +import org.apache.ranger.authorization.drill.resource.DrillAccessType; +import org.apache.ranger.authorization.drill.resource.DrillResource; +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 and single entry point for all Ranger + * access checks from Drill core hook points. + * + *

This class is designed to minimize the code changes required in Drill core. + * Each hook point is a single static method call:

+ * + *
{@code
+ * if (!DrillAccessControl.checkTableAccess(userName, storageEngineName, schemaPath, tableName)) {
+ *   throw UserException.permissionError()
+ *       .message("Access denied for user %s on table %s.%s", userName, schemaPath, tableName)
+ *       .build();
+ * }
+ * }
+ * + *

The class is initialized once at Drillbit startup via {@link #init(String)}. + * Until initialized, {@link #isEnabled()} returns {@code false} and all checks + * pass (fail-open), so the plugin is non-intrusive when disabled.

+ * + *

The service name is read from the configuration property + * {@code ranger.plugin.drill.service.name} (defined in ranger-drill-security.xml), + * but can also be passed directly to {@link #init(String)} for flexibility.

+ */ +public class DrillAccessControl { + + private static final Logger LOG = LoggerFactory.getLogger(DrillAccessControl.class); + + private static volatile boolean enabled = false; + private static volatile 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). + // TODO: Currently system schemas bypass authorization entirely. Future work: + // - Implement fine-grained system table access control (e.g. restrict + // INFORMATION_SCHEMA columns, or allow only SYS schema metadata queries). + // - Make SYSTEM_SCHEMAS configurable via drill-override.conf or Ranger + // service-def options, so deployments can extend or override the list. + // - Consider per-user system schema visibility (e.g. hide sys.memory_state + // from non-admin users) instead of an all-or-nothing bypass. + private static final Set SYSTEM_SCHEMAS = new HashSet<>(Arrays.asList( + "INFORMATION_SCHEMA", "SYS" + )); + + private DrillAccessControl() { + } + + /** + * Initializes the Ranger Drill plugin. Called once at Drillbit startup. + * + * @param serviceName the Ranger service instance name (must match a service created in Ranger Admin) + */ + public static synchronized void init(String serviceName) { + if (authorizer != null) { + return; + } + try { + LOG.info("Initializing Ranger Drill authorization plugin for service: {}", serviceName); + authorizer = new DrillAuthorizer(serviceName); + enabled = true; + LOG.info("Ranger Drill authorization plugin initialized successfully"); + } catch (Exception e) { + LOG.error("Failed to initialize Ranger Drill plugin — authorization DISABLED", e); + throw new RuntimeException("Failed to initialize Ranger Drill plugin — authorization disabled "+ serviceName + " with exception: " + e); + } + } + + /** + * @return {@code true} if the Ranger plugin is initialized + */ + public static boolean isEnabled() { + return enabled; + } + + /** + * Resolves the OS-level groups for a given user via Hadoop UGI. + * + * @param user the username + * @return a set of group names (never null, empty on failure) + */ + // TODO: Currently group resolution relies on the OS-level mapping configured + // in Hadoop UserGroupInformation (e.g. /etc/group on Linux). This may + // diverge from the groups configured in Ranger Admin (e.g. a "meta" group + // that exists only in Ranger but not on the Drillbit host). Future work: + // - Sync Ranger user/group information into Drill so that policy group + // checks use Ranger as the source of truth instead of the local OS. + // - Cache Ranger group lookups with a configurable TTL to avoid hitting + // Ranger Admin on every access check. + // - Fall back to OS groups when Ranger Admin is unavailable, to keep + // authorization working during Ranger outages. + 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) { + LOG.warn("Failed to determine groups for user={}", user, e); + return Collections.emptySet(); + } + } + + /** + * Checks table-level access. If Ranger is disabled, returns {@code true} (fail-open). + * On Ranger evaluation error, returns {@code false} (fail-closed). + * + * @param user the username requesting access + * @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 operator the access type + * @return {@code true} if access is allowed + */ + public static boolean checkTableAccess(String user, String dataSource, String schema, + String table, DrillAccessType operator) { + if (!enabled || authorizer == null) { + return true; // fail-open when disabled + } + // Bypass authorization for system schemas + if (isSystemSchema(schema)) { + return true; + } + try { + DrillResource resource = new DrillResource(); + resource.setUser(user); + resource.setGroups(getUserGroups(user)); + resource.setDataSource(dataSource != null ? dataSource : "drill"); + resource.setSchema(schema); + resource.setTable(table); + return authorizer.checkTableAccess(resource, operator); + } catch (Exception e) { + LOG.error("Checking table access for user={}, schema={}, table={}. with exception:{}", user, schema, table, e.toString()); + return false; // fail-closed on error + } + } + + /** + * Convenience overload defaulting to {@link DrillAccessType#SELECT}. + */ + public static boolean checkTableSelectAccess(String user, String dataSource, String schema, String table) { + return checkTableAccess(user, dataSource, schema, table, DrillAccessType.SELECT); + } + + /** + * Checks whether a table should be visible to the user (for SHOW TABLES / metadata queries). + * Returns {@code true} if the user has SELECT access OR if Ranger is disabled. + * + * @param user the username + * @param dataSource the Drill storage plugin name + * @param schema the schema path + * @param table the table name + * @return {@code true} if the table is visible + */ + public static boolean isTableVisible(String user, String dataSource, String schema, String table) { + return checkTableAccess(user, dataSource, schema, table, DrillAccessType.SELECT); + } + + // ======================================================================== + // DML/DDL operation-aware access checks + // + // IMPORTANT: INSERT and CTAS do NOT go through DrillTable.getGroupScan(). + // They have separate execution paths via AbstractSchema.createNewTable() + // (CTAS) and AbstractSchema.modifyTable() (INSERT). These methods provide + // the correct DrillAccessType for each DML/DDL operation, to be called at the + // corresponding AbstractSchema hook points. + // ======================================================================== + + /** + * Checks CREATE TABLE AS (CTAS) permission. + * Hook point: {@code AbstractSchema.createNewTable()} — called by CreateTableHandler. + * + * @param user the username + * @param dataSource the Drill storage plugin name + * @param schema the schema path + * @param table the target table name + * @return {@code true} if the user can create the table + */ + public static boolean checkCreateTable(String user, String dataSource, String schema, String table) { + return checkTableAccess(user, dataSource, schema, table, DrillAccessType.CREATE); + } + + /** + * Checks INSERT INTO permission. + * Hook point: {@code AbstractSchema.modifyTable()} — called by TableModifyPrel. + * + * @param user the username + * @param dataSource the Drill storage plugin name + * @param schema the schema path + * @param table the target table name + * @return {@code true} if the user can insert into the table + */ + public static boolean checkInsert(String user, String dataSource, String schema, String table) { + return checkTableAccess(user, dataSource, schema, table, DrillAccessType.INSERT); + } + + /** + * Checks DROP TABLE permission. + * Hook point: {@code AbstractSchema.dropTable()} — called by DropTableHandler. + * + * @param user the username + * @param dataSource the Drill storage plugin name + * @param schema the schema path + * @param table the table name + * @return {@code true} if the user can drop the table + */ + public static boolean checkDropTable(String user, String dataSource, String schema, String table) { + return checkTableAccess(user, dataSource, schema, table, DrillAccessType.DROP); + } + + /** + * Checks CREATE VIEW permission. + * Hook point: {@code AbstractSchema.createView()} — called by ViewHandler.CreateView. + * + * @param user the username + * @param dataSource the Drill storage plugin name + * @param schema the schema path + * @param viewName the view name + * @return {@code true} if the user can create the view + */ + public static boolean checkCreateView(String user, String dataSource, String schema, String viewName) { + return checkTableAccess(user, dataSource, schema, viewName, DrillAccessType.CREATE); + } + + /** + * Checks DROP VIEW permission. + * Hook point: {@code AbstractSchema.dropView()} — called by ViewHandler.DropView. + * + * @param user the username + * @param dataSource the Drill storage plugin name + * @param schema the schema path + * @param viewName the view name + * @return {@code true} if the user can drop the view + */ + public static boolean checkDropView(String user, String dataSource, String schema, String viewName) { + return checkTableAccess(user, dataSource, schema, viewName, DrillAccessType.DROP); + } + + /** + * Checks column-level access for a set of columns. Returns {@code true} only if the user + * has access to ALL specified columns. + * + * @param user the username + * @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 operator the access type + * @return {@code true} if access is allowed for every column + */ + public static boolean checkColumnAccess(String user, String dataSource, String schema, + String table, Set columns, DrillAccessType operator) { + if (!enabled || authorizer == null || isSystemSchema(schema)) { + return true; // fail-open when disabled + } + + try { + DrillResource resource = new DrillResource(); + resource.setUser(user); + resource.setGroups(getUserGroups(user)); + resource.setDataSource(dataSource != null ? dataSource : "drill"); + resource.setSchema(schema); + resource.setTable(table); + resource.setColumns(columns); + return authorizer.checkColumnAccess(resource, operator); + } catch (Exception e) { + LOG.error("Error checking column access for user={}, schema={}, table={}", user, schema, table, e); + return false; // fail-closed on error + } + } + + /** + * 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()) { + return true; + } + // 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()); + } +} diff --git a/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizer.java b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizer.java new file mode 100644 index 00000000000..f3c5715c17f --- /dev/null +++ b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizer.java @@ -0,0 +1,185 @@ +/* + * 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.authorization.drill.resource.DrillAccessResource; +import org.apache.ranger.authorization.drill.resource.DrillAccessType; +import org.apache.ranger.authorization.drill.resource.DrillRangerAccessRequest; +import org.apache.ranger.authorization.drill.resource.DrillResource; +import org.apache.ranger.plugin.policyengine.RangerAccessRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +public class DrillAuthorizer { + private static final Logger LOG = LoggerFactory.getLogger(DrillAuthorizer.class); + private RangerBaseAuthorizer authorizer; + + public DrillAuthorizer(String serviceName) { + authorizer = RangerBaseAuthorizer.getInstance(); + authorizer.init(serviceName); + } + + private boolean checkPermission(DrillRangerAccessRequest request) { + return authorizer.isAccessAllowed(request.toRangerRequest()); + } + + /** + * Build a DrillRangerAccessRequest from the given resource and access type, then check + * permission. This abstracts the common logic shared by table-level and column-level + * access checks. + * + * @param resource the DrillResource providing user, groups, etc. + * @param drillAccessResource the DrillAccessResource describing the accessed entity + * @param operator the access type to check + * @return the permission check result + */ + private boolean checkAccess(DrillResource resource, DrillAccessResource drillAccessResource, + DrillAccessType operator, RangerAccessRequest.ResourceMatchingScope scope) { + Set groups = new HashSet<>(); + if (resource.getGroups() != null) { + groups.addAll(resource.getGroups()); + } + + DrillRangerAccessRequest request = DrillRangerAccessRequest.builder() + .user(resource.getUser()) + .groups(groups) + .resource(drillAccessResource) + .accessType(operator) + .resourceMatchingScope(scope) + .build(); + + return checkPermission(request); + } + + public boolean checkTableAccess(DrillResource resource, DrillAccessType operator) { + if (!validateResource(resource, ValidationLevel.TABLE)) { + LOG.warn("MetaStoreResource validation failed for table access check"); + return false; + } + Optional schema = Optional.ofNullable(resource.getSchema()); + Optional table = Optional.ofNullable(resource.getTable()); + DrillAccessResource drillAccessResource = new DrillAccessResource(resource.getDataSource(), + schema, 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(resource, drillAccessResource, operator, + RangerAccessRequest.ResourceMatchingScope.SELF_OR_DESCENDANTS); + + LOG.info("checkTableAccess result for user={}, datasource={}, schema={}, table={}, " + + "operator={}: result={}", + resource.getUser(), resource.getDataSource(), resource.getSchema(), + resource.getTable(), operator.name(), result); + + return result; + } + + /** + * Validate that required fields of MetaStoreResource are non-empty (full validation, defaults + * to column level) + * + * @param resource the resource object to validate + * @return true if validation passes, false otherwise + */ + public boolean validateResource(DrillResource resource) { + return validateResource(resource, ValidationLevel.COLUMN); + } + + public boolean checkColumnAccess(DrillResource resource, DrillAccessType operator) { + if (!validateResource(resource, ValidationLevel.COLUMN)) { + LOG.warn("MetaStoreResource validation failed for table access check"); + return false; + } + Optional schema = Optional.ofNullable(resource.getSchema()); + Optional table = Optional.ofNullable(resource.getTable()); + + for (String column : resource.getColumns()) { + Optional columnOpt = Optional.of(column); + DrillAccessResource drillAccessResource = new DrillAccessResource(resource.getDataSource(), schema, table, columnOpt); + + // Column-level check uses SELF for exact column matching: only policies + // whose column resource matches the requested column will be applied. + boolean allowed = checkAccess(resource, drillAccessResource, operator, + RangerAccessRequest.ResourceMatchingScope.SELF); + + LOG.info("checkColumnAccess result for user={}, datasource={}, schema={}, table={}, " + + "column={}, operator={}: result={}", + resource.getUser(), resource.getDataSource(), resource.getSchema(), + resource.getTable(), column, operator.name(), allowed); + + if (!allowed) { + // Fail fast on first denied column — no need to check the rest. + LOG.warn("Column access denied for user={}, column={}.{}.{}", + resource.getUser(), resource.getDataSource(), resource.getSchema(), + resource.getTable()); + return false; + } + } + return true; + } + + /** + * Validate that required fields of MetaStoreResource are non-empty (with specified validation + * level) + * + * @param resource the resource object to validate + * @param validationLevel the validation level + * @return true if validation passes, false otherwise + */ + private boolean validateResource(DrillResource resource, ValidationLevel validationLevel) { + boolean allValid = true; + if (resource == null) { + LOG.error("MetaStoreResource is null"); + return false; + } + if (resource.getUser() == null || resource.getUser().trim().isEmpty()) { + LOG.error("MetaStoreResource user is null or empty"); + return false; + } + if (resource.getDataSource() == null || resource.getDataSource().trim().isEmpty()) { + LOG.error("MetaStoreResource dataSource is null or empty"); + return false; + } + if (validationLevel.ordinal() >= ValidationLevel.SCHEMA.ordinal()) { + if (resource.getSchema() == null || resource.getSchema().trim().isEmpty()) { + LOG.error("MetaStoreResource schema is null or empty"); + return false; + } + } + if (validationLevel.ordinal() >= ValidationLevel.TABLE.ordinal()) { + if (resource.getTable() == null || resource.getTable().trim().isEmpty()) { + LOG.error("MetaStoreResource table is null or empty"); + return false; + } + } + if (validationLevel.ordinal() >= ValidationLevel.COLUMN.ordinal()) { + if (resource.getColumns() == null || resource.getColumns().isEmpty()) { + LOG.error("MetaStoreResource columns is null or empty"); + return false; + } + allValid = resource.getColumns().stream() + .allMatch(column -> column != null && !column.trim().isEmpty()); + } + return allValid; + } +} diff --git a/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerBaseAuthorizer.java b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerBaseAuthorizer.java new file mode 100644 index 00000000000..65d4a87bb0a --- /dev/null +++ b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerBaseAuthorizer.java @@ -0,0 +1,98 @@ +/* + * 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 LOG = LoggerFactory.getLogger(RangerBaseAuthorizer.class); + + private static RangerBaseAuthorizer instance; + 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(); + LOG.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) { + LOG.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(); + } + } + + public void cleanUp() { + if (plugin != null) { + plugin.cleanup(); + } + } +} diff --git a/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerDrillPlugin.java b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/RangerDrillPlugin.java new file mode 100644 index 00000000000..18ad05fd3f9 --- /dev/null +++ b/drill-ranger/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_PRESTO_APPID = "drill"; + + public RangerDrillPlugin(String serviceName) { + super(SERVICE_TYPE, serviceName, RANGER_PRESTO_APPID); + } +} diff --git a/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/ValidationLevel.java b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/ValidationLevel.java new file mode 100644 index 00000000000..f3eefedb012 --- /dev/null +++ b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/authorizer/ValidationLevel.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; + +/** + * Resource validation level enum. + * Controls the depth of validation in the validateResource method. + */ +public enum ValidationLevel { + /** Validate up to the datasource level (user, dataSource). */ + DATASOURCE, + /** Validate up to the schema level (user, dataSource, schema). */ + SCHEMA, + /** Validate up to the table level (user, dataSource, schema, table). */ + TABLE, + /** Full validation including columns (user, dataSource, schema, table, columns). */ + COLUMN +} diff --git a/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessResource.java b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessResource.java new file mode 100644 index 00000000000..950e8dff5ab --- /dev/null +++ b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessResource.java @@ -0,0 +1,94 @@ +/* + * 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 LOG = LoggerFactory.getLogger(DrillAccessResource.class); + + public DrillAccessResource() { + } + + 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 (LOG.isDebugEnabled()) { + LOG.debug("AccessResource set value: {} = {}", key, value); + } + } + } + + public DrillAccessResource(String catalogName, Optional schema, Optional table) { + setValue(RangerDrillResource.DATASOURCE.toString(), catalogName); + schema.ifPresent(s -> setValue(RangerDrillResource.SCHEMA.toString(), s)); + table.ifPresent(s -> setValue(RangerDrillResource.TABLE.toString(), s)); + } + + public DrillAccessResource(String catalogName, Optional schema, Optional table, + Optional column) { + setValue(RangerDrillResource.DATASOURCE.toString(), catalogName); + 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 getCatalogName() { + return (String) getValue(RangerDrillResource.DATASOURCE.toString()); + } + + public String getTable() { + return (String) getValue(RangerDrillResource.TABLE.toString()); + } + + public String getCatalog() { + return (String) getValue(RangerDrillResource.SCHEMA.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/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessType.java b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillAccessType.java new file mode 100644 index 00000000000..0d1a0c2301b --- /dev/null +++ b/drill-ranger/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, SHOW, USE, SELECT, INSERT, DELETE; +} diff --git a/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequest.java b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequest.java new file mode 100644 index 00000000000..37aad503693 --- /dev/null +++ b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequest.java @@ -0,0 +1,149 @@ +/* + * 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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashSet; +import java.util.Set; + + +public class DrillRangerAccessRequest { + + private static final Logger LOG = LoggerFactory.getLogger(DrillRangerAccessRequest.class); + + 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/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillResource.java b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillResource.java new file mode 100644 index 00000000000..cc8d972be16 --- /dev/null +++ b/drill-ranger/drill-ranger-plugin/src/main/java/org/apache/ranger/authorization/drill/resource/DrillResource.java @@ -0,0 +1,76 @@ +/* + * 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 java.util.Set; + +public class DrillResource { + private String user; + private Set groups; + private String dataSource; + private String schema; + private String table; + private Set columns; + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + public Set getGroups() { + return groups; + } + + public void setGroups(Set groups) { + this.groups = groups; + } + + public String getDataSource() { + return dataSource; + } + + public void setDataSource(String dataSource) { + this.dataSource = dataSource; + } + + public String getSchema() { + return schema; + } + + public void setSchema(String schema) { + this.schema = schema; + } + + public String getTable() { + return table; + } + + public void setTable(String table) { + this.table = table; + } + + public Set getColumns() { + return columns; + } + + public void setColumns(Set columns) { + this.columns = columns; + } +} diff --git a/drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControlTest.java b/drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControlTest.java new file mode 100644 index 00000000000..9aa3f02fdf4 --- /dev/null +++ b/drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAccessControlTest.java @@ -0,0 +1,261 @@ +/* + * 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.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.MockedStatic; + +import java.lang.reflect.Field; +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.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link DrillAccessControl} static facade. + * Covers fail-open (disabled), system-schema bypass, fail-closed (exception) + * and user-group resolution behavior. + */ +public class DrillAccessControlTest { + + /** + * 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 {@code checkTableAccess}/{@code checkColumnAccess} — gets a deterministic + * empty group set without touching the OS. + */ + private MockedStatic ugiMock; + private UserGroupInformation mockUgi; + + @BeforeEach + public void setUp() throws Exception { + setStaticField(DrillAccessControl.class, "enabled", false); + setStaticField(DrillAccessControl.class, "authorizer", null); + + // 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( + org.mockito.ArgumentMatchers.anyString())).thenReturn(mockUgi); + when(mockUgi.getGroupNames()).thenReturn(new String[0]); + } + + @AfterEach + public void tearDown() throws Exception { + if (ugiMock != null) { + ugiMock.close(); + ugiMock = null; + } + setStaticField(DrillAccessControl.class, "enabled", false); + setStaticField(DrillAccessControl.class, "authorizer", null); + } + + @Test + public void isEnabled_returnsFalse_beforeInit() { + assertFalse(DrillAccessControl.isEnabled()); + } + + @Test + public void checkTableAccess_returnsTrue_whenNotEnabled() { + assertTrue(DrillAccessControl.checkTableAccess( + "root", "mysql", "shf", "orders", DrillAccessType.SELECT)); + } + + @Test + public void checkColumnAccess_returnsTrue_whenNotEnabled() { + Set columns = new HashSet<>(); + columns.add("amount"); + assertTrue(DrillAccessControl.checkColumnAccess( + "root", "mysql", "shf", "orders", columns, DrillAccessType.SELECT)); + } + + @Test + public void checkTableAccess_bypassesSystemSchema_informationSchema() throws Exception { + DrillAuthorizer mockAuthorizer = mock(DrillAuthorizer.class); + setStaticField(DrillAccessControl.class, "enabled", true); + setStaticField(DrillAccessControl.class, "authorizer", mockAuthorizer); + + assertTrue(DrillAccessControl.checkTableAccess( + "root", "dfs", "INFORMATION_SCHEMA", "TABLES", DrillAccessType.SELECT)); + verify(mockAuthorizer, never()).checkTableAccess( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + public void checkTableAccess_bypassesSystemSchema_sys_caseInsensitive() throws Exception { + DrillAuthorizer mockAuthorizer = mock(DrillAuthorizer.class); + setStaticField(DrillAccessControl.class, "enabled", true); + setStaticField(DrillAccessControl.class, "authorizer", mockAuthorizer); + + for (String schema : new String[] {"sys", "Sys", "SYS"}) { + assertTrue(DrillAccessControl.checkTableAccess( + "root", "dfs", schema, "DRILLBITS", DrillAccessType.SELECT), + "schema=" + schema + " should bypass authorization"); + } + verify(mockAuthorizer, never()).checkTableAccess( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + public void checkTableAccess_bypassesSystemSchema_compoundPath() throws Exception { + DrillAuthorizer mockAuthorizer = mock(DrillAuthorizer.class); + setStaticField(DrillAccessControl.class, "enabled", true); + setStaticField(DrillAccessControl.class, "authorizer", mockAuthorizer); + + // Top-level segment "information_schema" should match, even with compound path + assertTrue(DrillAccessControl.checkTableAccess( + "root", "dfs", "information_schema.tables", "COLUMNS", DrillAccessType.SELECT)); + verify(mockAuthorizer, never()).checkTableAccess( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + public void checkTableAccess_bypassesSystemSchema_nullSchema() throws Exception { + DrillAuthorizer mockAuthorizer = mock(DrillAuthorizer.class); + setStaticField(DrillAccessControl.class, "enabled", true); + setStaticField(DrillAccessControl.class, "authorizer", mockAuthorizer); + + assertTrue(DrillAccessControl.checkTableAccess( + "root", "dfs", null, "orders", DrillAccessType.SELECT)); + } + + @Test + public void checkTableAccess_bypassesSystemSchema_emptySchema() throws Exception { + DrillAuthorizer mockAuthorizer = mock(DrillAuthorizer.class); + setStaticField(DrillAccessControl.class, "enabled", true); + setStaticField(DrillAccessControl.class, "authorizer", mockAuthorizer); + + assertTrue(DrillAccessControl.checkTableAccess( + "root", "dfs", "", "orders", DrillAccessType.SELECT)); + assertTrue(DrillAccessControl.checkTableAccess( + "root", "dfs", " ", "orders", DrillAccessType.SELECT)); + } + + @Test + public void checkTableAccess_delegatesToAuthorizer_whenEnabled() throws Exception { + DrillAuthorizer mockAuthorizer = mock(DrillAuthorizer.class); + when(mockAuthorizer.checkTableAccess( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(DrillAccessType.SELECT))).thenReturn(true); + setStaticField(DrillAccessControl.class, "enabled", true); + setStaticField(DrillAccessControl.class, "authorizer", mockAuthorizer); + + boolean result = DrillAccessControl.checkTableAccess( + "root", "mysql", "shf", "orders", DrillAccessType.SELECT); + assertTrue(result); + verify(mockAuthorizer).checkTableAccess( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(DrillAccessType.SELECT)); + } + + @Test + public void checkTableAccess_returnsFalse_whenAuthorizerThrows() throws Exception { + DrillAuthorizer mockAuthorizer = mock(DrillAuthorizer.class); + when(mockAuthorizer.checkTableAccess( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())).thenThrow(new RuntimeException("boom")); + setStaticField(DrillAccessControl.class, "enabled", true); + setStaticField(DrillAccessControl.class, "authorizer", mockAuthorizer); + + boolean result = DrillAccessControl.checkTableAccess( + "root", "mysql", "shf", "orders", DrillAccessType.SELECT); + assertFalse(result); + } + + @Test + public void checkColumnAccess_returnsFalse_whenAuthorizerThrows() throws Exception { + DrillAuthorizer mockAuthorizer = mock(DrillAuthorizer.class); + when(mockAuthorizer.checkColumnAccess( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())).thenThrow(new RuntimeException("boom")); + setStaticField(DrillAccessControl.class, "enabled", true); + setStaticField(DrillAccessControl.class, "authorizer", mockAuthorizer); + + Set columns = new HashSet<>(); + columns.add("amount"); + boolean result = DrillAccessControl.checkColumnAccess( + "root", "mysql", "shf", "orders", columns, DrillAccessType.SELECT); + assertFalse(result); + } + + @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("root"); + 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("root"); + assertNotNull(groups); + assertTrue(groups.isEmpty()); + } + + // ======================================================================== + // Reflection helpers + // ======================================================================== + + private static void setStaticField(Class clazz, String fieldName, Object value) throws Exception { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(null, value); + } +} diff --git a/drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizerTest.java b/drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizerTest.java new file mode 100644 index 00000000000..9f4878245df --- /dev/null +++ b/drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/authorizer/DrillAuthorizerTest.java @@ -0,0 +1,229 @@ +/* + * 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.authorization.drill.resource.DrillAccessType; +import org.apache.ranger.authorization.drill.resource.DrillResource; +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.HashSet; +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.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mockStatic; + +/** + * Unit tests for {@link DrillAuthorizer}. + * Covers resource validation, table-level and column-level access checks, + * and per-column fail-fast behavior. + */ +public class DrillAuthorizerTest { + + /** + * 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 DrillResource buildValidResource() { + DrillResource r = new DrillResource(); + r.setUser("root"); + r.setDataSource("mysql"); + r.setSchema("shf"); + r.setTable("orders"); + Set columns = new LinkedHashSet<>(); + columns.add("amount"); + r.setColumns(columns); + return r; + } + + @Test + public void validateResource_nullResource_returnsFalse() { + DrillAuthorizer authorizer = newAuthorizer(mock(RangerBaseAuthorizer.class)); + assertFalse(authorizer.validateResource(null)); + } + + @Test + public void validateResource_emptyUser_returnsFalse() { + DrillAuthorizer authorizer = newAuthorizer(mock(RangerBaseAuthorizer.class)); + DrillResource r = buildValidResource(); + r.setUser(null); + assertFalse(authorizer.validateResource(r)); + + r.setUser(""); + assertFalse(authorizer.validateResource(r)); + } + + @Test + public void validateResource_emptyDataSource_returnsFalse() { + DrillAuthorizer authorizer = newAuthorizer(mock(RangerBaseAuthorizer.class)); + DrillResource r = buildValidResource(); + r.setDataSource(null); + assertFalse(authorizer.validateResource(r)); + + r.setDataSource(""); + assertFalse(authorizer.validateResource(r)); + } + + @Test + public void validateResource_emptySchema_returnsFalse_atSchemaLevel() { + DrillAuthorizer authorizer = newAuthorizer(mock(RangerBaseAuthorizer.class)); + DrillResource r = buildValidResource(); + r.setSchema(null); + // COLUMN-level validate requires schema non-empty too + assertFalse(authorizer.validateResource(r)); + + r.setSchema(""); + assertFalse(authorizer.validateResource(r)); + } + + @Test + public void validateResource_emptyTable_returnsFalse_atTableLevel() { + DrillAuthorizer authorizer = newAuthorizer(mock(RangerBaseAuthorizer.class)); + DrillResource r = buildValidResource(); + r.setTable(null); + assertFalse(authorizer.validateResource(r)); + + r.setTable(""); + assertFalse(authorizer.validateResource(r)); + } + + @Test + public void validateResource_emptyColumns_returnsFalse_atColumnLevel() { + DrillAuthorizer authorizer = newAuthorizer(mock(RangerBaseAuthorizer.class)); + DrillResource r = buildValidResource(); + r.setColumns(null); + assertFalse(authorizer.validateResource(r)); + + r.setColumns(Collections.emptySet()); + assertFalse(authorizer.validateResource(r)); + } + + @Test + public void validateResource_validResource_returnsTrue() { + DrillAuthorizer authorizer = newAuthorizer(mock(RangerBaseAuthorizer.class)); + DrillResource r = buildValidResource(); + assertTrue(authorizer.validateResource(r)); + } + + @Test + public void checkTableAccess_returnsFalse_whenValidationFails() { + RangerBaseAuthorizer mockBase = mock(RangerBaseAuthorizer.class); + DrillAuthorizer authorizer = newAuthorizer(mockBase); + + DrillResource r = buildValidResource(); + r.setTable(null); // missing table -> validation fails at TABLE level + assertFalse(authorizer.checkTableAccess(r, DrillAccessType.SELECT)); + verify(mockBase, never()).isAccessAllowed(any()); + } + + @Test + public void checkTableAccess_delegatesToAuthorizer_withSelfOrDescendantsScope() { + RangerBaseAuthorizer mockBase = mock(RangerBaseAuthorizer.class); + when(mockBase.isAccessAllowed(any())).thenReturn(true); + DrillAuthorizer authorizer = newAuthorizer(mockBase); + + DrillResource r = buildValidResource(); + boolean result = authorizer.checkTableAccess(r, DrillAccessType.SELECT); + + assertTrue(result); + ArgumentCaptor captor = ArgumentCaptor.forClass(RangerAccessRequest.class); + verify(mockBase).isAccessAllowed(captor.capture()); + RangerAccessRequest captured = captor.getValue(); + assertEquals( + RangerAccessRequest.ResourceMatchingScope.SELF_OR_DESCENDANTS, + captured.getResourceMatchingScope()); + } + + @Test + public void checkColumnAccess_returnsFalse_whenValidationFails() { + RangerBaseAuthorizer mockBase = mock(RangerBaseAuthorizer.class); + DrillAuthorizer authorizer = newAuthorizer(mockBase); + + DrillResource r = buildValidResource(); + r.setColumns(Collections.emptySet()); // validation fails + assertFalse(authorizer.checkColumnAccess(r, DrillAccessType.SELECT)); + verify(mockBase, never()).isAccessAllowed(any()); + } + + @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); + + DrillResource r = buildValidResource(); + // Use LinkedHashSet for deterministic iteration order + Set columns = new LinkedHashSet<>(Arrays.asList("c1", "c2", "c3")); + r.setColumns(columns); + + boolean result = authorizer.checkColumnAccess(r, DrillAccessType.SELECT); + assertFalse(result); + // 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); + + DrillResource r = buildValidResource(); + Set columns = new LinkedHashSet<>(Arrays.asList("c1", "c2", "c3")); + r.setColumns(columns); + + boolean result = authorizer.checkColumnAccess(r, DrillAccessType.SELECT); + assertTrue(result); + verify(mockBase, times(3)).isAccessAllowed(any()); + } + + @Test + public void checkColumnAccess_emptyColumnInSet_returnsFalse() { + RangerBaseAuthorizer mockBase = mock(RangerBaseAuthorizer.class); + DrillAuthorizer authorizer = newAuthorizer(mockBase); + + DrillResource r = buildValidResource(); + Set columns = new HashSet<>(); + columns.add(""); + r.setColumns(columns); + + assertFalse(authorizer.checkColumnAccess(r, DrillAccessType.SELECT)); + verify(mockBase, never()).isAccessAllowed(any()); + } +} diff --git a/drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/resource/DrillAccessResourceTest.java b/drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/resource/DrillAccessResourceTest.java new file mode 100644 index 00000000000..1cf70741710 --- /dev/null +++ b/drill-ranger/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.getCatalogName()); + 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.getCatalogName()); + 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.getCatalogName()); + assertNotNull(r.getTable()); + } + + @Test + public void constructor_emptyTable_doesNotSetTableKey() { + DrillAccessResource r = new DrillAccessResource( + "mysql", Optional.of("shf"), Optional.empty()); + assertEquals("mysql", r.getCatalogName()); + 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.getCatalogName()); + } + + @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/drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequestTest.java b/drill-ranger/drill-ranger-plugin/src/test/java/org/apache/ranger/authorization/drill/resource/DrillRangerAccessRequestTest.java new file mode 100644 index 00000000000..5ee53800118 --- /dev/null +++ b/drill-ranger/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/drill-ranger/drill-ranger-service/pom.xml b/drill-ranger/drill-ranger-service/pom.xml new file mode 100644 index 00000000000..899bcf5726f --- /dev/null +++ b/drill-ranger/drill-ranger-service/pom.xml @@ -0,0 +1,139 @@ + + + + 4.0.0 + + + org.apache.drill + drill-ranger-parent + 1.23.0-SNAPSHOT + + + ranger-drill-service + Drill : Ranger Drill Service Plugin + + Apache Ranger service plugin for Apache Drill. + Deployed to Ranger Admin (NOT to the Drillbit). Provides validateConfig + (connection test) and lookupResource (schema/table/column autocomplete in + the Ranger policy editor) by connecting to Drill via REST API. + Compiled with JDK 8 bytecode for compatibility with Ranger Admin running + on JDK 8. Uses only JDK-standard HTTP (HttpURLConnection) and Jackson + (provided by Ranger Admin) with no Drill dependencies. + + + + + + + + + + org.apache.ranger + ranger-plugins-common + ${ranger.version} + provided + + + + commons-logging + commons-logging + + + javax.servlet + javax.servlet-api + + + javax.servlet.jsp + jsp-api + + + + + + + com.fasterxml.jackson.core + jackson-databind + provided + + + + + org.slf4j + slf4j-api + provided + + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 8 + + + + + diff --git a/drill-ranger/drill-ranger-service/src/main/java/org/apache/ranger/services/drill/RangerServiceDrill.java b/drill-ranger/drill-ranger-service/src/main/java/org/apache/ranger/services/drill/RangerServiceDrill.java new file mode 100644 index 00000000000..3bce42d42ae --- /dev/null +++ b/drill-ranger/drill-ranger-service/src/main/java/org/apache/ranger/services/drill/RangerServiceDrill.java @@ -0,0 +1,457 @@ +/* + * 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.services.drill; + +import org.apache.ranger.plugin.model.RangerPolicy; +import org.apache.ranger.plugin.model.RangerService; +import org.apache.ranger.plugin.model.RangerServiceDef; +import org.apache.ranger.plugin.service.RangerBaseService; +import org.apache.ranger.plugin.service.ResourceLookupContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Ranger service plugin for Apache Drill. + * + *

Deployed into Ranger Admin (NOT into the Drillbit). Provides: + *

    + *
  • {@link #validateConfig()} - tests connectivity to the Drill cluster + * by calling the Drill REST API ({@code POST /query.json}).
  • + *
  • {@link #lookupResource(ResourceLookupContext)} - enumerates + * datasource / schema / table / column resources via + * {@code INFORMATION_SCHEMA} queries through the REST API, so the + * Ranger policy editor can auto-complete resource paths.
  • + *
+ * + *

Uses Drill's REST API (default port 8047) instead of JDBC, so this + * module can be compiled with JDK 8 and deployed into a JDK 8 Ranger Admin + * without pulling in Drill's JDK 11+ dependencies. + * + *

Connection configuration (must match the service-def JSON + * {@code serviceConfigOptions}): + *

    + *
  • {@code username} - Drill user name (required)
  • + *
  • {@code password} - Drill user password (optional)
  • + *
  • {@code drill.connection.url} - Drill REST endpoint, e.g. + * {@code http://host:8047} (required).
  • + *
+ */ +public class RangerServiceDrill extends RangerBaseService { + + private static final Logger LOG = LoggerFactory.getLogger(RangerServiceDrill.class); + + // Service config keys (must match ranger-servicedef-drill.json) + private static final String CONFIG_USERNAME = "username"; + private static final String CONFIG_PASSWORD = "password"; + private static final String CONFIG_DRILL_URL = "drill.connection.url"; + + // Resource names (must match ranger-servicedef-drill.json, lowercase per Ranger naming rules) + private static final String RESOURCE_DATASOURCE = "datasource"; + private static final String RESOURCE_SCHEMA = "schema"; + private static final String RESOURCE_TABLE = "table"; + private static final String RESOURCE_COLUMN = "column"; + + // HTTP connect / read timeout (milliseconds) + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 30_000; + + // SQL templates for resource lookup. Each %s is filled via String.format + // with the corresponding escaped resource value. TABLES and COLUMNS are + // reserved keywords in Drill SQL and must be backtick-quoted. + private static final String SQL_VALIDATE_CONNECTION = "SELECT 1"; + + private static final String SQL_LOOKUP_DATASOURCE = + "SELECT DISTINCT SPLIT_PART(SCHEMA_NAME, '.', 1) AS DATASOURCE " + + "FROM INFORMATION_SCHEMA.SCHEMATA " + + "WHERE SCHEMA_NAME LIKE '%.%' " + + "ORDER BY 1"; + + // %s = datasource (e.g. "mysql") + private static final String SQL_LOOKUP_SCHEMA = + "SELECT SPLIT_PART(SCHEMA_NAME, '.', 2) AS SCHEMA " + + "FROM INFORMATION_SCHEMA.SCHEMATA " + + "WHERE SCHEMA_NAME LIKE '%s.%%' " + + "ORDER BY 1"; + + // %s = full table schema (e.g. "mysql.shf") + private static final String SQL_LOOKUP_TABLE = + "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.`TABLES` " + + "WHERE TABLE_SCHEMA = '%s' " + + "ORDER BY 1"; + + // %1$s = full table schema, %2$s = table name + private static final String SQL_LOOKUP_COLUMN = + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.`COLUMNS` " + + "WHERE TABLE_SCHEMA = '%s' " + + "AND TABLE_NAME = '%s' " + + "ORDER BY 1"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public void init(RangerServiceDef serviceDef, RangerService service) { + super.init(serviceDef, service); + LOG.debug("RangerServiceDrill initialized for service={}", + service != null ? service.getName() : "null"); + } + + /** + * Validates the service configuration by testing connectivity to Drill. + * + * @return a map with {@code status} = {@code SUCCESS} or {@code FAILURE} + * and a human-readable {@code message}. + */ + @Override + public Map validateConfig() throws Exception { + Map result = new HashMap<>(); + + String username = getConfig(CONFIG_USERNAME); + if (isBlank(username)) { + return failure(result, "Drill user name is required"); + } + String baseUrl = getConfig(CONFIG_DRILL_URL); + if (isBlank(baseUrl)) { + return failure(result, "Drill connection URL is required"); + } + String password = getConfig(CONFIG_PASSWORD); + + String normalizedUrl; + try { + normalizedUrl = buildBaseUrl(baseUrl); + } catch (IllegalArgumentException e) { + return failure(result, "Invalid drill.connection.url: " + e.getMessage()); + } + + LOG.info("Validating Drill service connection to {}", normalizedUrl); + try { + String response = executeQuery(normalizedUrl, username, password, SQL_VALIDATE_CONNECTION); + // A successful query returns JSON with a "rows" array + JsonNode root = MAPPER.readTree(response); + if (root != null && root.has("rows") && root.get("rows").isArray()) { + result.put("status", "SUCCESS"); + result.put("message", "Connection test succeeded"); + LOG.info("Drill connection validation succeeded for {}", normalizedUrl); + } else { + return failure(result, "Unexpected response from Drill: " + response); + } + } catch (Exception e) { + LOG.error("Drill connection validation failed for url={}", normalizedUrl, e); + return failure(result, "Connection test failed: " + e.getMessage()); + } + return result; + } + + /** + * Lists Drill resources for the Ranger policy editor autocomplete. + * + *

Supported resource levels (must match the service-def JSON): + *

    + *
  • {@code datasource} - distinct storage plugins from + * {@code INFORMATION_SCHEMA.SCHEMATA}
  • + *
  • {@code schema} - schema names filtered by the selected datasource
  • + *
  • {@code table} - table names filtered by datasource + schema
  • + *
  • {@code column} - column names filtered by datasource + schema + table
  • + *
+ * + * @param context carries the requested resource name and the already-selected + * parent resources in {@link ResourceLookupContext#getResources()} + * @return a list of matching resource names (never {@code null}) + */ + @Override + public List lookupResource(ResourceLookupContext context) throws Exception { + if (context == null) { + return Collections.emptyList(); + } + String resourceName = context.getResourceName(); + // getResources() returns Map> in Ranger 2.8.0: + // each parent resource name maps to a list of selected values. + Map> hints = context.getResources() != null + ? context.getResources() : Collections.emptyMap(); + + if (isBlank(resourceName)) { + return Collections.emptyList(); + } + + String username = getConfig(CONFIG_USERNAME); + String password = getConfig(CONFIG_PASSWORD); + String baseUrl = buildBaseUrl(getConfig(CONFIG_DRILL_URL)); + + LOG.debug("lookupResource: resource={}, hints={}", resourceName, hints); + + try { + switch (resourceName) { + case RESOURCE_DATASOURCE: + // Drill's INFORMATION_SCHEMA.SCHEMATA has no STORAGE_PLUGIN column. + // The datasource (storage plugin name) is the first segment of + // SCHEMA_NAME (e.g. "mysql.shf" -> "mysql"). Use SUBSTR_INDEX to + // extract it, then DISTINCT to deduplicate. + return extractFirstColumnValues(executeQuery(baseUrl, username, password, + SQL_LOOKUP_DATASOURCE)); + case RESOURCE_SCHEMA: { + String datasource = firstHint(hints, RESOURCE_DATASOURCE); + if (isBlank(datasource)) { + return Collections.emptyList(); + } + // For a given datasource, list schema names by stripping the + // "datasource." prefix from SCHEMA_NAME (e.g. "mysql.shf" -> "shf"). + // Schemas without a dot (e.g. plain "mysql") are filtered out. + String sql = String.format(SQL_LOOKUP_SCHEMA, escapeSql(datasource)); + return extractFirstColumnValues(executeQuery(baseUrl, username, password, sql)); + } + case RESOURCE_TABLE: { + String datasource = firstHint(hints, RESOURCE_DATASOURCE); + String schema = firstHint(hints, RESOURCE_SCHEMA); + if (isBlank(datasource) || isBlank(schema)) { + return Collections.emptyList(); + } + // TABLE_SCHEMA in INFORMATION_SCHEMA.`TABLES` uses the full qualified + // form "datasource.schema" (e.g. "mysql.shf"), so concatenate the + // selected datasource and schema before filtering. + // NOTE: TABLES is a reserved keyword in Drill SQL and must be + // backtick-quoted; without quotes the parser rejects the query. + String tableSchema = escapeSql(datasource) + "." + escapeSql(schema); + String sql = String.format(SQL_LOOKUP_TABLE, tableSchema); + return extractFirstColumnValues(executeQuery(baseUrl, username, password, sql)); + } + case RESOURCE_COLUMN: { + String datasource = firstHint(hints, RESOURCE_DATASOURCE); + String schema = firstHint(hints, RESOURCE_SCHEMA); + String table = firstHint(hints, RESOURCE_TABLE); + if (isBlank(datasource) || isBlank(schema) || isBlank(table)) { + return Collections.emptyList(); + } + // COLUMNS is also a reserved keyword in Drill SQL — backtick-quote it. + String tableSchema = escapeSql(datasource) + "." + escapeSql(schema); + String sql = String.format(SQL_LOOKUP_COLUMN, tableSchema, escapeSql(table)); + return extractFirstColumnValues(executeQuery(baseUrl, username, password, sql)); + } + default: + LOG.warn("Unknown resource name: {}", resourceName); + return Collections.emptyList(); + } + } catch (Exception e) { + LOG.error("lookupResource failed for resource={}, hints={}", resourceName, hints, e); + throw e; + } + } + + @Override + public List getDefaultRangerPolicies() throws Exception { + return super.getDefaultRangerPolicies(); + } + + // ======================================================================== + // REST API helpers + // ======================================================================== + + /** + * Normalizes the user-supplied {@code drill.connection.url} to a base URL + * like {@code http://host:8047}. Trims trailing slashes. + */ + static String buildBaseUrl(String configuredUrl) { + if (isBlank(configuredUrl)) { + throw new IllegalArgumentException("drill.connection.url is empty"); + } + String url = configuredUrl.trim(); + // Strip trailing slashes + while (url.endsWith("/")) { + url = url.substring(0, url.length() - 1); + } + return url; + } + + /** + * Executes a SQL query via the Drill REST API. + *

POSTs to {@code /query.json} with a JSON body + * {@code {"queryType":"SQL","query":""}} using HTTP Basic auth. + * + * @param baseUrl Drill REST endpoint, e.g. {@code http://host:8047} + * @param username Drill user name + * @param password Drill password (may be null or empty) + * @param sql the SQL statement to execute + * @return the raw JSON response string from Drill + */ + private static String executeQuery(String baseUrl, String username, String password, String sql) + throws Exception { + String endpoint = baseUrl + "/query.json"; + Map payload = new HashMap<>(); + payload.put("queryType", "SQL"); + payload.put("query", sql); + String body = MAPPER.writeValueAsString(payload); + + HttpURLConnection conn = null; + try { + URL url = new URL(endpoint); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Accept", "application/json"); + + // HTTP Basic auth + String creds = username + ":" + (password == null ? "" : password); + String encoded = Base64.getEncoder().encodeToString( + creds.getBytes(StandardCharsets.UTF_8)); + conn.setRequestProperty("Authorization", "Basic " + encoded); + + // Write request body + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + + int code = conn.getResponseCode(); + InputStream is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream(); + String response = readAll(is); + + if (code < 200 || code >= 300) { + throw new RuntimeException("Drill REST API returned HTTP " + code + + ": " + truncate(response, 500)); + } + return response; + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + /** + * Extracts the first column value from each row of a Drill REST API response. + *

Drill returns JSON like: + *

{@code
+   * {"columns":["COL1"],"rows":[{"COL1":"value1"},{"COL1":"value2"}]}
+   * }
+ * + *

This method is used for ALL resource levels (datasource/schema/table/column) + * because every lookup SQL selects exactly one column. The "column" in the + * method name refers to the JSON result column, not the Ranger column resource. + * + * @param jsonResponse the raw JSON response from {@code POST /query.json} + * @return list of string values from the first column + */ + private static List extractFirstColumnValues(String jsonResponse) throws Exception { + JsonNode root = MAPPER.readTree(jsonResponse); + if (root == null || !root.has("rows") || !root.get("rows").isArray()) { + return Collections.emptyList(); + } + + // Determine the first column name from the "columns" array + String firstColumn = null; + if (root.has("columns") && root.get("columns").isArray()) { + JsonNode cols = root.get("columns"); + if (cols.size() > 0) { + firstColumn = cols.get(0).asText(); + } + } + + List result = new ArrayList<>(); + for (JsonNode row : root.get("rows")) { + if (firstColumn != null && row.has(firstColumn)) { + String value = row.get(firstColumn).asText(); + if (value != null && !value.trim().isEmpty()) { + result.add(value.trim()); + } + } else if (row.isObject() && row.size() > 0) { + // Fallback: use the first field in the row + String value = row.elements().next().asText(); + if (value != null && !value.trim().isEmpty()) { + result.add(value.trim()); + } + } + } + return result; + } + + private static String readAll(InputStream is) throws Exception { + if (is == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(is, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + } + } + return sb.toString(); + } + + private static String truncate(String s, int maxLen) { + if (s == null) { + return ""; + } + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "..."; + } + + // ======================================================================== + // Common helpers + // ======================================================================== + + private String getConfig(String key) { + if (configs == null) { + return null; + } + Object value = configs.get(key); + return value == null ? null : value.toString().trim(); + } + + private static String firstHint(Map> hints, String resourceName) { + if (hints == null) { + return null; + } + List values = hints.get(resourceName); + if (values == null || values.isEmpty()) { + return null; + } + return values.get(0); + } + + private static String escapeSql(String value) { + return value == null ? "" : value.replace("'", "''"); + } + + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } + + private static Map failure(Map result, String message) { + result.put("status", "FAILURE"); + result.put("message", message); + return result; + } +} diff --git a/drill-ranger/drill-ranger-service/src/test/java/org/apache/ranger/services/drill/RangerServiceDrillTest.java b/drill-ranger/drill-ranger-service/src/test/java/org/apache/ranger/services/drill/RangerServiceDrillTest.java new file mode 100644 index 00000000000..585a3c51fb1 --- /dev/null +++ b/drill-ranger/drill-ranger-service/src/test/java/org/apache/ranger/services/drill/RangerServiceDrillTest.java @@ -0,0 +1,344 @@ +/* + * 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.services.drill; + +import org.apache.ranger.plugin.service.ResourceLookupContext; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +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.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link RangerServiceDrill}. + * Covers URL normalization, SQL escaping, JSON parsing, hint extraction, + * and the control-flow edge cases of lookupResource / validateConfig that + * can be exercised without a live Drill cluster. + */ +public class RangerServiceDrillTest { + + // ======================================================================== + // buildBaseUrl (package-private static — directly callable from same package) + // ======================================================================== + + @Test + public void buildBaseUrl_stripsTrailingSlash() { + assertEquals("http://host:8047", RangerServiceDrill.buildBaseUrl("http://host:8047/")); + } + + @Test + public void buildBaseUrl_handlesMultipleTrailingSlashes() { + assertEquals("http://host:8047", RangerServiceDrill.buildBaseUrl("http://host:8047///")); + } + + @Test + public void buildBaseUrl_trimsWhitespace() { + assertEquals("http://host:8047", RangerServiceDrill.buildBaseUrl(" http://host:8047 ")); + } + + @Test + public void buildBaseUrl_throwsForBlankInput() { + assertThrows(IllegalArgumentException.class, () -> RangerServiceDrill.buildBaseUrl(null)); + assertThrows(IllegalArgumentException.class, () -> RangerServiceDrill.buildBaseUrl("")); + assertThrows(IllegalArgumentException.class, () -> RangerServiceDrill.buildBaseUrl(" ")); + } + + @Test + public void buildBaseUrl_preservesProtocol() { + assertEquals("https://host:8047", RangerServiceDrill.buildBaseUrl("https://host:8047")); + assertEquals("https://host:8047", RangerServiceDrill.buildBaseUrl("https://host:8047/")); + } + + // ======================================================================== + // escapeSql (private static — reflection) + // ======================================================================== + + @Test + public void escapeSql_doublesSingleQuote() throws Exception { + assertEquals("it''s", invokeEscapeSql("it's")); + } + + @Test + public void escapeSql_nullReturnsEmpty() throws Exception { + assertEquals("", invokeEscapeSql(null)); + } + + @Test + public void escapeSql_noQuotes_unchanged() throws Exception { + assertEquals("mysql", invokeEscapeSql("mysql")); + } + + // ======================================================================== + // firstHint (private static — reflection) + // ======================================================================== + + @Test + public void firstHint_returnsFirstValue() throws Exception { + Map> hints = new HashMap<>(); + hints.put("datasource", java.util.Arrays.asList("mysql", "dfs")); + assertEquals("mysql", invokeFirstHint(hints, "datasource")); + } + + @Test + public void firstHint_nullHints_returnsNull() throws Exception { + assertNull(invokeFirstHint(null, "datasource")); + } + + @Test + public void firstHint_emptyList_returnsNull() throws Exception { + Map> hints = new HashMap<>(); + hints.put("datasource", Collections.emptyList()); + assertNull(invokeFirstHint(hints, "datasource")); + } + + @Test + public void firstHint_missingKey_returnsNull() throws Exception { + Map> hints = new HashMap<>(); + hints.put("schema", java.util.Arrays.asList("shf")); + assertNull(invokeFirstHint(hints, "datasource")); + } + + // ======================================================================== + // extractFirstColumnValues (private static — reflection) + // ======================================================================== + + @Test + public void extractFirstColumnValues_parsesRows() throws Exception { + String json = "{\"columns\":[\"COL1\"],\"rows\":[{\"COL1\":\"v1\"},{\"COL1\":\"v2\"}]}"; + List result = invokeExtractFirstColumnValues(json); + assertEquals(java.util.Arrays.asList("v1", "v2"), result); + } + + @Test + public void extractFirstColumnValues_emptyRows_returnsEmptyList() throws Exception { + String json = "{\"columns\":[\"COL1\"],\"rows\":[]}"; + List result = invokeExtractFirstColumnValues(json); + assertTrue(result.isEmpty()); + } + + @Test + public void extractFirstColumnValues_missingRowsKey_returnsEmptyList() throws Exception { + String json = "{\"columns\":[\"COL1\"]}"; + List result = invokeExtractFirstColumnValues(json); + assertTrue(result.isEmpty()); + } + + @Test + public void extractFirstColumnValues_skipsBlankValues() throws Exception { + String json = "{\"columns\":[\"COL1\"],\"rows\":[{\"COL1\":\" \"},{\"COL1\":\"v2\"}]}"; + List result = invokeExtractFirstColumnValues(json); + assertEquals(Collections.singletonList("v2"), result); + } + + @Test + public void extractFirstColumnValues_fallbackToFirstField() throws Exception { + // No "columns" array — should fall back to the first field of each row + String json = "{\"rows\":[{\"X\":\"a\"},{\"Y\":\"b\"}]}"; + List result = invokeExtractFirstColumnValues(json); + // Each row contributes its first field value + assertEquals(2, result.size()); + assertTrue(result.contains("a")); + assertTrue(result.contains("b")); + } + + // ======================================================================== + // lookupResource edge cases (no executeQuery needed) + // ======================================================================== + + @Test + public void lookupResource_nullContext_returnsEmpty() throws Exception { + RangerServiceDrill service = newServiceWithConfigs( + "root", "pw", "http://host:8047"); + List result = service.lookupResource(null); + assertTrue(result.isEmpty()); + } + + @Test + public void lookupResource_blankResourceName_returnsEmpty() throws Exception { + RangerServiceDrill service = newServiceWithConfigs( + "root", "pw", "http://host:8047"); + ResourceLookupContext ctx = new ResourceLookupContext(); + ctx.setResourceName(""); + List result = service.lookupResource(ctx); + assertTrue(result.isEmpty()); + } + + @Test + public void lookupResource_unknownResourceName_returnsEmpty() throws Exception { + RangerServiceDrill service = newServiceWithConfigs( + "root", "pw", "http://host:8047"); + ResourceLookupContext ctx = new ResourceLookupContext(); + ctx.setResourceName("foo"); + // lookupResource throws on unknown resource via the default branch, + // but the catch block re-throws. Wrap in try/catch and assert empty only + // if it returns. If it throws, that's also acceptable behavior. + try { + List result = service.lookupResource(ctx); + assertTrue(result.isEmpty()); + } catch (Exception e) { + // Acceptable: unknown resource name may throw + } + } + + @Test + public void lookupResource_schema_blankDatasource_returnsEmpty() throws Exception { + RangerServiceDrill service = newServiceWithConfigs( + "root", "pw", "http://host:8047"); + ResourceLookupContext ctx = new ResourceLookupContext(); + ctx.setResourceName("schema"); + ctx.setResources(Collections.emptyMap()); + List result = service.lookupResource(ctx); + assertTrue(result.isEmpty()); + } + + @Test + public void lookupResource_table_missingHints_returnsEmpty() throws Exception { + RangerServiceDrill service = newServiceWithConfigs( + "root", "pw", "http://host:8047"); + // Only datasource hint, missing schema + Map> hints = new HashMap<>(); + hints.put("datasource", Collections.singletonList("mysql")); + ResourceLookupContext ctx = new ResourceLookupContext(); + ctx.setResourceName("table"); + ctx.setResources(hints); + List result = service.lookupResource(ctx); + assertTrue(result.isEmpty()); + } + + @Test + public void lookupResource_column_missingTableHint_returnsEmpty() throws Exception { + RangerServiceDrill service = newServiceWithConfigs( + "root", "pw", "http://host:8047"); + Map> hints = new HashMap<>(); + hints.put("datasource", Collections.singletonList("mysql")); + hints.put("schema", Collections.singletonList("shf")); + ResourceLookupContext ctx = new ResourceLookupContext(); + ctx.setResourceName("column"); + ctx.setResources(hints); + List result = service.lookupResource(ctx); + assertTrue(result.isEmpty()); + } + + @Test + public void lookupResource_column_missingSchemaHint_returnsEmpty() throws Exception { + RangerServiceDrill service = newServiceWithConfigs( + "root", "pw", "http://host:8047"); + Map> hints = new HashMap<>(); + hints.put("datasource", Collections.singletonList("mysql")); + hints.put("table", Collections.singletonList("orders")); + ResourceLookupContext ctx = new ResourceLookupContext(); + ctx.setResourceName("column"); + ctx.setResources(hints); + List result = service.lookupResource(ctx); + assertTrue(result.isEmpty()); + } + + // ======================================================================== + // validateConfig edge cases (no executeQuery needed) + // ======================================================================== + + @Test + public void validateConfig_missingUsername_returnsFailure() throws Exception { + RangerServiceDrill service = newServiceWithConfigs( + null, "pw", "http://host:8047"); + Map result = service.validateConfig(); + assertEquals("FAILURE", result.get("status")); + assertNotNull(result.get("message")); + } + + @Test + public void validateConfig_blankUsername_returnsFailure() throws Exception { + RangerServiceDrill service = newServiceWithConfigs( + " ", "pw", "http://host:8047"); + Map result = service.validateConfig(); + assertEquals("FAILURE", result.get("status")); + } + + @Test + public void validateConfig_missingUrl_returnsFailure() throws Exception { + RangerServiceDrill service = newServiceWithConfigs( + "root", "pw", null); + Map result = service.validateConfig(); + assertEquals("FAILURE", result.get("status")); + } + + @Test + public void validateConfig_blankUrl_returnsFailure() throws Exception { + RangerServiceDrill service = newServiceWithConfigs( + "root", "pw", " "); + Map result = service.validateConfig(); + assertEquals("FAILURE", result.get("status")); + } + + // ======================================================================== + // Reflection helpers + // ======================================================================== + + private static String invokeEscapeSql(String input) throws Exception { + Method m = RangerServiceDrill.class.getDeclaredMethod("escapeSql", String.class); + m.setAccessible(true); + return (String) m.invoke(null, input); + } + + @SuppressWarnings("unchecked") + private static List invokeExtractFirstColumnValues(String json) throws Exception { + Method m = RangerServiceDrill.class.getDeclaredMethod("extractFirstColumnValues", String.class); + m.setAccessible(true); + return (List) m.invoke(null, json); + } + + private static String invokeFirstHint(Map> hints, String key) throws Exception { + Method m = RangerServiceDrill.class.getDeclaredMethod("firstHint", Map.class, String.class); + m.setAccessible(true); + return (String) m.invoke(null, hints, key); + } + + /** + * Creates a RangerServiceDrill instance with the {@code configs} field + * pre-populated. The {@code configs} field is declared in + * {@link org.apache.ranger.plugin.service.RangerBaseService} as a + * protected {@code Map}. + */ + private static RangerServiceDrill newServiceWithConfigs( + String username, String password, String url) throws Exception { + RangerServiceDrill service = new RangerServiceDrill(); + Map configs = new HashMap<>(); + if (username != null) { + configs.put("username", username); + } + if (password != null) { + configs.put("password", password); + } + if (url != null) { + configs.put("drill.connection.url", url); + } + Field configsField = service.getClass().getSuperclass().getDeclaredField("configs"); + configsField.setAccessible(true); + configsField.set(service, configs); + return service; + } +} diff --git a/drill-ranger/pom.xml b/drill-ranger/pom.xml new file mode 100644 index 00000000000..0e0b0cd6ce8 --- /dev/null +++ b/drill-ranger/pom.xml @@ -0,0 +1,74 @@ + + + + 4.0.0 + + + org.apache.drill + drill-root + 1.23.0-SNAPSHOT + + + + drill-ranger-parent + pom + Drill : Ranger Integration Parent + + Parent module aggregating the Drill Ranger authorization plugin + (drill-ranger-plugin) and the Drill Ranger service plugin + (drill-ranger-service). + + + + 2.8.0 + + 1.19.4 + + + + drill-ranger-plugin + drill-ranger-service + + diff --git a/exec/java-exec/pom.xml b/exec/java-exec/pom.xml index fe2c229a9a0..5e76055df1a 100644 --- a/exec/java-exec/pom.xml +++ b/exec/java-exec/pom.xml @@ -697,6 +697,12 @@ swagger-jaxrs2-servlet-initializer-v2-jakarta ${swagger.version} + + org.apache.drill + drill-ranger-plugin + 1.23.0-SNAPSHOT + provided + 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..891e363de1e 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,11 @@ 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"; + // Ranger authorization + public static final String RANGER_AUTH_ENABLED = "drill.exec.security.ranger.enabled"; + public static final String RANGER_SERVICE_NAME = "drill.exec.security.ranger.service.name"; + public static final String RANGER_AUTHORIZER_IMPL = "drill.exec.security.ranger.impl"; + /** 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..617760fabae --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/planner/sql/conversion/ColumnAccessChecker.java @@ -0,0 +1,351 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.drill.exec.planner.sql.conversion; + +import org.apache.calcite.plan.RelOptTable; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.RelShuttleImpl; +import org.apache.calcite.rel.core.TableScan; +import org.apache.calcite.rel.logical.LogicalAggregate; +import org.apache.calcite.rel.logical.LogicalFilter; +import org.apache.calcite.rel.logical.LogicalJoin; +import org.apache.calcite.rel.logical.LogicalProject; +import org.apache.calcite.rel.logical.LogicalSort; +import org.apache.calcite.rel.metadata.RelColumnOrigin; +import org.apache.calcite.rel.metadata.RelMetadataQuery; +import org.apache.calcite.rex.RexInputRef; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.rex.RexSubQuery; +import org.apache.calcite.rex.RexVisitorImpl; +import org.apache.drill.common.config.DrillConfig; +import org.apache.drill.common.exceptions.UserException; +import org.apache.drill.exec.planner.sql.SchemaUtilities; +import org.apache.drill.exec.rpc.user.UserSession; +import org.apache.drill.exec.security.ranger.AccessAuthorizer; +import org.apache.drill.exec.security.ranger.AccessAuthorizerFactory; +import org.apache.ranger.authorization.drill.resource.DrillAccessType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Visitor that traverses a RelNode tree and enforces column-level SELECT authorization + * via the configured {@link AccessAuthorizer} (Ranger by default). + * + *

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.

+ * + *

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<>(); + + ColumnAccessChecker(UserSession session, DrillConfig drillConfig, RelMetadataQuery mq) { + this.session = session; + this.drillConfig = drillConfig; + this.mq = mq; + } + + /** + * Entry point: traverse the tree and enforce column-level access. + */ + void check(RelNode root) { + // Also trace the root node's output columns (covers bare TableScan root or + // top-level Project output). + traceOutputColumns(root); + + // Walk the tree to collect RexInputRef origins from all expression-bearing nodes. + root.accept(this); + } + + // ------------------------------------------------------------------ + // RelShuttle overrides — collect RexInputRefs from expression-bearing nodes + // ------------------------------------------------------------------ + + @Override + public RelNode visit(LogicalProject project) { + collectRefs(project.getProjects(), project.getInput()); + return super.visit(project); + } + + @Override + public RelNode visit(LogicalFilter filter) { + if (filter.getCondition() != null) { + analyzeRex(filter.getCondition(), filter.getInput(), -1, null); + } + return super.visit(filter); + } + + @Override + public RelNode visit(LogicalJoin join) { + if (join.getCondition() != null) { + int leftCount = join.getLeft().getRowType().getFieldCount(); + analyzeRex(join.getCondition(), join.getRight(), leftCount, join.getLeft()); + } + return super.visit(join); + } + + @Override + public RelNode visit(LogicalAggregate aggregate) { + for (int i : aggregate.getGroupSet()) { + traceColumnOrigin(aggregate.getInput(), i); + } + return super.visit(aggregate); + } + + @Override + public RelNode visit(LogicalSort sort) { + if (sort.getCollation() != null) { + sort.getCollation().getFieldCollations().forEach(fc -> + traceColumnOrigin(sort.getInput(), fc.getFieldIndex())); + } + return super.visit(sort); + } + + @Override + public RelNode visit(TableScan scan) { + RelOptTable table = scan.getTable(); + + // Determine which columns to check: traced columns, or ALL if none were traced + // (SELECT * FROM t case). + Set 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<>(); + rex.accept(new RexRefCollector(refs, subQueries)); + for (int refIndex : refs) { + if (leftCount >= 0 && refIndex < leftCount) { + traceColumnOrigin(leftInput, refIndex); + } else if (leftCount >= 0) { + traceColumnOrigin(inputNode, refIndex - leftCount); + } else { + traceColumnOrigin(inputNode, refIndex); + } + } + for (RexSubQuery sq : subQueries) { + // Trace the subquery's output columns to their table-scan origins. + // For scalar subqueries (e.g. SELECT sum(user_id) FROM t), this traces + // the aggregate output back to the underlying table column. + traceOutputColumns(sq.rel); + // Recursively visit the subquery's RelNode tree so that RexInputRefs + // inside the subquery (e.g. columns in WHERE/SELECT of the subquery) + // are also collected and traced. + sq.rel.accept(this); + } + } + + /** + * Enforces column-level access for the given table and column set. + */ + private void enforceColumnAccess(RelOptTable table, Set columns) { + AccessAuthorizer authorizer = AccessAuthorizerFactory.getAuthorizer(drillConfig); + if (!authorizer.isEnabled()) { + return; // fail-open when disabled + } + + // Resolve datasource / schema / table from the qualified name, consistent + // with DrillCalciteCatalogReader.checkTableAccess(). This works for ALL + // table types (DrillTable, JdbcTable, etc.) — previously this method + // required a DrillTable and skipped JdbcTable, leaving JDBC storage + // plugin tables without column-level authorization. + List qualifiedName = table.getQualifiedName(); + String tableName = qualifiedName.get(qualifiedName.size() - 1); + String dataSource; + String schemaPath; + if (qualifiedName.size() > 2) { + dataSource = qualifiedName.get(0); + schemaPath = SchemaUtilities.getSchemaPath(qualifiedName.subList(1, qualifiedName.size() - 1)); + } else if (qualifiedName.size() == 2) { + dataSource = qualifiedName.get(0); + schemaPath = DrillCalciteCatalogReader.getDefaultSchemaByDataSource(dataSource); + } else { + dataSource = tableName; + schemaPath = DrillCalciteCatalogReader.getDefaultSchemaByDataSource(dataSource); + } + String userName = session.getCredentials().getUserName(); + + if (!authorizer.checkColumnAccess(userName, dataSource, schemaPath, tableName, columns, DrillAccessType.SELECT.name())) { + throw UserException.permissionError() + .message("Access denied: user '%s' lacks SELECT privilege on one or more columns " + + "(%s) of table %s.%s.%s", userName, columns, dataSource, schemaPath, tableName) + .build(logger); + } + } + + /** + * RexVisitor that collects all {@link RexInputRef} indices and + * {@link RexSubQuery} instances encountered in a {@link RexNode} tree. + * + *

{@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 static final class RexRefCollector extends RexVisitorImpl { + private final Set refs; + private final List subQueries; + + RexRefCollector(Set refs, List subQueries) { + super(true); + this.refs = refs; + this.subQueries = subQueries; + } + + @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; + } + } +} 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..0278694d663 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,9 @@ 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.ranger.AccessAuthorizer; +import org.apache.drill.exec.security.ranger.AccessAuthorizerFactory; +import org.apache.ranger.authorization.drill.resource.DrillAccessType; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; @@ -103,14 +106,99 @@ 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, names); + + DrillTable drillTable = table.unwrap(DrillTable.class); + if (drillTable != null) { + drillTable.setOptions(session.getOptions()); + drillTable.setTableMetadataProviderManager(tableCache.getUnchecked(DrillTableKey.of(names, drillTable))); + } } return table; } + /** + * Checks SELECT permission on the resolved table via the configured {@link AccessAuthorizer} + * (Ranger by default). No-op when authorization is disabled (fail-open). System schemas + * (INFORMATION_SCHEMA, sys) are bypassed inside the authorizer implementation. + * + *

Extracts datasource/schema/table from the resolved qualified name so it works + * for both DrillTable (native storage plugins) and non-DrillTable (JDBC storage plugin). + * The {@code names} argument is used as a fallback to determine the datasource when + * the qualified name does not expose it. + */ + private void checkTableAccess(Prepare.PreparingTable table, List names) { + AccessAuthorizer authorizer = AccessAuthorizerFactory.getAuthorizer(drillConfig); + if (!authorizer.isEnabled()) { + return; // fail-open when disabled + } + // Use the resolved qualified name (includes default schema resolution) rather than + // the raw input names, which may be incomplete when the user omits the schema. + // + // Ranger four-level resource model: datasource / schema / table / column. + // The first segment of qualifiedName is the datasource (storage plugin name); + // the LAST segment is the table; any segments in between form the schema path. + // For example "mysql.shf.users" -> datasource=mysql, schema=shf, table=users. + // The schema MUST NOT include the datasource prefix, otherwise Ranger policy + // matching fails (policy has schema=shf but request sends schema=mysql.shf). + // + // Some backends have no schema concept (e.g. a flat file store). To keep the + // four-level model uniform, we synthesize a default schema per datasource via + // getDefaultSchemaByDataSource(). The default branch returns the datasource + // name itself so each storage plugin gets its own default schema namespace + // until an explicit mapping is added. + List qualifiedName = table.getQualifiedName(); + String tableName = qualifiedName.get(qualifiedName.size() - 1); + String dataSource; + String schemaPath; + if (qualifiedName.size() > 2) { + // datasource.schema.table OR datasource.subschema.table + dataSource = qualifiedName.get(0); + schemaPath = SchemaUtilities.getSchemaPath(qualifiedName.subList(1, qualifiedName.size() - 1)); + } else if (qualifiedName.size() == 2) { + // datasource.table — backend has no schema; synthesize a default so the + // four-level resource stays complete (Ranger policy matching requires a + // non-null schema key when schema is a mandatory resource). + dataSource = qualifiedName.get(0); + schemaPath = getDefaultSchemaByDataSource(dataSource); + } else { + // Single-element qualified name: fall back to the input names list to find the datasource. + dataSource = !names.isEmpty() ? names.get(0) : tableName; + schemaPath = getDefaultSchemaByDataSource(dataSource); + } + String userName = session.getCredentials().getUserName(); + + if (!authorizer.checkTableAccess(userName, dataSource, schemaPath, tableName, DrillAccessType.SELECT.name())) { + throw UserException.permissionError() + .message("Access denied: user '%s' lacks SELECT privilege on %s.%s.%s", + userName, dataSource, schemaPath, tableName) + .build(logger); + } + } + + /** + * Returns the default schema name to use when a table's qualified name does not + * contain an explicit schema segment (i.e. two-segment {@code datasource.table} + * or a single-segment fallback). This keeps the Ranger four-level resource + * model complete even for backends that have no native schema concept. + * + *

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 + */ + static String getDefaultSchemaByDataSource(String dataSource) { + return switch (dataSource.toLowerCase()) { + case "dfs", "cp" -> "default"; + default -> dataSource; + }; + } + 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..1c426ba82fc 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 @@ -245,6 +245,12 @@ public RelRoot toRel(final SqlNode validatedNode) { RelNode project = LogicalProject.create(rel.rel, Collections.emptyList(), expressions, rel.validatedRowType); rel = RelRoot.of(project, rel.validatedRowType, rel.kind); } + + // Column-level SELECT authorization check. Done after SqlToRelConverter has + // resolved all column references (so we can trace each to its TableScan) + // and before flattenTypes/optimization (so column references are intact). + new ColumnAccessChecker(session, drillConfig, cluster.getMetadataQuery()).check(rel.rel); + return rel.withRel(sqlToRelConverter.flattenTypes(rel.rel, true)); } diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/AccessAuthorizer.java b/exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/AccessAuthorizer.java new file mode 100644 index 00000000000..c4fda90c2d4 --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/AccessAuthorizer.java @@ -0,0 +1,69 @@ +/* + * 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 java.util.Set; + +/** + * Drill access authorization SPI interface. + * + *

The {@code exec/java-exec} module only depends on this interface; concrete + * implementations (such as the Ranger plugin in {@code drill-ranger}) are loaded + * reflectively via {@link AccessAuthorizerFactory} based on configuration. + */ +public interface AccessAuthorizer { + + /** + * Initializes the authorizer. Called once at Drillbit startup. + * + * @param serviceName the authorization service name (e.g. Ranger service instance name) + */ + void init(String serviceName); + + /** + * @return {@code true} if the authorizer is enabled and successfully initialized + */ + boolean isEnabled(); + + /** + * Checks table-level access permission. + * + * @param user the querying user name + * @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 string ("SELECT", "CREATE", "INSERT", etc.) + * @return {@code true} if access is allowed (fail-open returns {@code true} when disabled) + */ + boolean checkTableAccess(String user, String dataSource, String schema, + String table, String 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 name + * @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 string ("SELECT", etc.) + * @return {@code true} if access is allowed for every column (fail-open returns {@code true} when disabled) + */ + boolean checkColumnAccess(String user, String dataSource, String schema, + String table, Set columns, String accessType); +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/AccessAuthorizerFactory.java b/exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/AccessAuthorizerFactory.java new file mode 100644 index 00000000000..b3955f764e5 --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/AccessAuthorizerFactory.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.drill.exec.security.ranger; + +import org.apache.drill.common.config.DrillConfig; +import org.apache.drill.exec.ExecConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Singleton factory for {@link AccessAuthorizer}. Follows the same configuration-driven + * reflective loading pattern as + * {@link org.apache.drill.exec.rpc.user.security.UserAuthenticatorFactory}. + * + *

When {@code drill.exec.security.ranger.enabled} is {@code false} (the default), + * a {@link NoOpAccessAuthorizer} is returned. When enabled, the implementation class + * named by {@code drill.exec.security.ranger.impl} is loaded reflectively and + * initialized with the configured service name.

+ */ +public class AccessAuthorizerFactory { + private static final Logger logger = LoggerFactory.getLogger(AccessAuthorizerFactory.class); + + // Default values used when the corresponding config keys are absent. + private static final String DEFAULT_AUTHORIZER_IMPL = + "org.apache.drill.exec.security.ranger.RangerAccessAuthorizer"; + private static final String DEFAULT_SERVICE_NAME = "drill"; + + private static volatile AccessAuthorizer instance; + + private AccessAuthorizerFactory() { + } + + /** + * Returns the singleton {@link AccessAuthorizer} instance, initializing it from + * the given configuration on first call. + * + * @param config the Drill configuration + * @return the authorizer (never {@code null}) + */ + public static AccessAuthorizer getAuthorizer(DrillConfig config) { + if (instance != null) { + return instance; + } + synchronized (AccessAuthorizerFactory.class) { + if (instance != null) { + return instance; + } + instance = createAuthorizer(config); + return instance; + } + } + + private static AccessAuthorizer createAuthorizer(DrillConfig config) { + boolean enabled = config.hasPath(ExecConstants.RANGER_AUTH_ENABLED) + && config.getBoolean(ExecConstants.RANGER_AUTH_ENABLED); + if (!enabled) { + logger.info("Ranger authorization is disabled (drill.exec.security.ranger.enabled=false)"); + return new NoOpAccessAuthorizer(); + } + String impl = config.hasPath(ExecConstants.RANGER_AUTHORIZER_IMPL) + ? config.getString(ExecConstants.RANGER_AUTHORIZER_IMPL) + : DEFAULT_AUTHORIZER_IMPL; + String serviceName = config.hasPath(ExecConstants.RANGER_SERVICE_NAME) + ? config.getString(ExecConstants.RANGER_SERVICE_NAME) + : DEFAULT_SERVICE_NAME; + logger.info("Initializing Ranger authorizer: impl={}, service={}", impl, serviceName); + try { + Class clazz = Class.forName(impl); + AccessAuthorizer authorizer = (AccessAuthorizer) clazz.getDeclaredConstructor().newInstance(); + authorizer.init(serviceName); + logger.info("Ranger authorizer initialized, enabled={}", authorizer.isEnabled()); + return authorizer; + } catch (Exception e) { + logger.error("Failed to initialize Ranger authorizer {}, falling back to NoOp (fail-open)", impl, e); + throw new RuntimeException("Failed to initialize Ranger authorizer impl:" + impl + ", serviceName:" + serviceName); + } + } +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/NoOpAccessAuthorizer.java b/exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/NoOpAccessAuthorizer.java new file mode 100644 index 00000000000..f019914dbfc --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/NoOpAccessAuthorizer.java @@ -0,0 +1,47 @@ +/* + * 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 java.util.Set; + +/** + * No-op implementation that allows all access. Used when Ranger authorization + * is disabled or when the configured implementation fails to initialize. + */ +public class NoOpAccessAuthorizer implements AccessAuthorizer { + @Override + public void init(String serviceName) { + // no-op + } + + @Override + public boolean isEnabled() { + return false; + } + + @Override + public boolean checkTableAccess(String user, String dataSource, String schema, + String table, String accessType) { + return true; // fail-open + } + + @Override + public boolean checkColumnAccess(String user, String dataSource, String schema, + String table, Set columns, String accessType) { + return true; // fail-open + } +} diff --git a/exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizer.java b/exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizer.java new file mode 100644 index 00000000000..ece1120a12f --- /dev/null +++ b/exec/java-exec/src/main/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizer.java @@ -0,0 +1,69 @@ +/* + * 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.authorization.drill.authorizer.DrillAccessControl; +import org.apache.ranger.authorization.drill.resource.DrillAccessType; + +import java.util.Set; + +/** + * {@link AccessAuthorizer} SPI implementation backed by Ranger. + * + *

This class is the bridge between Drill core (which only depends on the + * {@code AccessAuthorizer} interface in {@code drill-java-exec}) and the Ranger + * plugin (this module). All calls are delegated to {@link DrillAccessControl}, + * the static facade that owns the fail-open/fail-closed policy and system-schema + * bypass logic.

+ */ +public class RangerAccessAuthorizer implements AccessAuthorizer { + + @Override + public void init(String serviceName) { + DrillAccessControl.init(serviceName); + } + + @Override + public boolean isEnabled() { + return DrillAccessControl.isEnabled(); + } + + @Override + public boolean checkTableAccess(String user, String dataSource, String schema, + String table, String accessType) { + DrillAccessType operator = toAccessType(accessType); + return DrillAccessControl.checkTableAccess(user, dataSource, schema, table, operator); + } + + @Override + public boolean checkColumnAccess(String user, String dataSource, String schema, + String table, Set columns, String accessType) { + DrillAccessType operator = toAccessType(accessType); + return DrillAccessControl.checkColumnAccess(user, dataSource, schema, table, columns, operator); + } + + private static DrillAccessType toAccessType(String accessType) { + if (accessType == null) { + return DrillAccessType.SELECT; + } + try { + return DrillAccessType.valueOf(accessType.toUpperCase()); + } catch (IllegalArgumentException e) { + return DrillAccessType.SELECT; // unknown type defaults to SELECT + } + } +} 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..4f8809efbe4 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.ranger.AccessAuthorizerFactory; 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,8 @@ public void run() throws Exception { final DrillbitContext drillbitContext = manager.getContext(); storageRegistry = drillbitContext.getStorage(); storageRegistry.init(); + // Initialize Ranger access authorizer if enabled + AccessAuthorizerFactory.getAuthorizer(context.getConfig()); drillbitContext.getOptionManager().init(); javaPropertiesToSystemOptions(); manager.getContext().getRemoteFunctionRegistry().init(context.getConfig(), storeProvider, coord); diff --git a/exec/java-exec/src/main/resources/drill-module.conf b/exec/java-exec/src/main/resources/drill-module.conf index 887fa15bff4..ee8baee5eff 100644 --- a/exec/java-exec/src/main/resources/drill-module.conf +++ b/exec/java-exec/src/main/resources/drill-module.conf @@ -263,6 +263,11 @@ drill.exec: { security.user.encryption.ssl: { enabled: false } + security.ranger: { + enabled: false, + service.name: "drill", + impl: "org.apache.drill.exec.security.ranger.RangerAccessAuthorizer" + } trace: { directory: "/tmp/drill-trace", filesystem: "file:///" diff --git a/exec/java-exec/src/test/java/org/apache/drill/exec/planner/sql/conversion/DrillCalciteCatalogReaderTest.java b/exec/java-exec/src/test/java/org/apache/drill/exec/planner/sql/conversion/DrillCalciteCatalogReaderTest.java new file mode 100644 index 00000000000..915ed74e1f0 --- /dev/null +++ b/exec/java-exec/src/test/java/org/apache/drill/exec/planner/sql/conversion/DrillCalciteCatalogReaderTest.java @@ -0,0 +1,70 @@ +/* + * 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.drill.test.BaseTest; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +/** + * Unit tests for {@link DrillCalciteCatalogReader#getDefaultSchemaByDataSource}. + * The method is package-private static; this test class must live in the same + * package to invoke it directly. + * + *

The datasource argument is always a non-null storage plugin name obtained + * from a table's qualified name, so null/empty inputs are not exercised here. + * The switch matches on lowercased input: explicit cases ({@code "dfs"}, {@code cp}) + * return {@code "default"}; the {@code default} branch returns the ORIGINAL + * datasource name (preserving case). These tests pin that behavior so future + * additions of explicit cases are intentional.

+ */ +public class DrillCalciteCatalogReaderTest extends BaseTest { + + @Test + public void getDefaultSchemaByDataSource_returnsDefault_forDfs() { + assertEquals("default", DrillCalciteCatalogReader.getDefaultSchemaByDataSource("dfs")); + } + + @Test + public void getDefaultSchemaByDataSource_returnsDefault_forCp() { + assertEquals("default", DrillCalciteCatalogReader.getDefaultSchemaByDataSource("cp")); + } + + @Test + public void getDefaultSchemaByDataSource_returnsDataSource_forUnknownPlugin() { + // No explicit case for "mysql" — falls through to default branch which + // returns the input unchanged. + assertEquals("mysql", DrillCalciteCatalogReader.getDefaultSchemaByDataSource("mysql")); + } + + @Test + public void getDefaultSchemaByDataSource_isCaseInsensitive() { + // The switch matches on lowercased input. "DFS" lowercases to "dfs" which + // hits the explicit case and returns "default"; "MySql" lowercases to + // "mysql" which falls through to default and returns the original input. + assertEquals("default", DrillCalciteCatalogReader.getDefaultSchemaByDataSource("DFS")); + assertEquals("MySql", DrillCalciteCatalogReader.getDefaultSchemaByDataSource("MySql")); + } + + @Test + public void getDefaultSchemaByDataSource_returnsDataSource_forCustomName() { + assertEquals("my_plugin", DrillCalciteCatalogReader.getDefaultSchemaByDataSource("my_plugin")); + assertEquals("hbase", DrillCalciteCatalogReader.getDefaultSchemaByDataSource("hbase")); + } +} diff --git a/exec/java-exec/src/test/java/org/apache/drill/exec/security/ranger/AccessAuthorizerFactoryTest.java b/exec/java-exec/src/test/java/org/apache/drill/exec/security/ranger/AccessAuthorizerFactoryTest.java new file mode 100644 index 00000000000..51d8cc31993 --- /dev/null +++ b/exec/java-exec/src/test/java/org/apache/drill/exec/security/ranger/AccessAuthorizerFactoryTest.java @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.drill.exec.security.ranger; + +import org.apache.drill.common.config.DrillConfig; +import org.apache.drill.exec.ExecConstants; +import org.apache.drill.test.BaseTest; +import org.apache.ranger.authorization.drill.authorizer.DrillAccessControl; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.Properties; + +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; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mockStatic; + +/** + * Unit tests for {@link AccessAuthorizerFactory}. + * Covers the configuration-driven reflective loading of the {@link AccessAuthorizer} + * SPI, including default fallbacks, caching, and failure modes. + */ +public class AccessAuthorizerFactoryTest extends BaseTest { + + /** + * Resets the {@code instance} singleton before each test so that the + * double-checked locking in {@link AccessAuthorizerFactory#getAuthorizer} + * re-runs the initialization path. The field is {@code private static volatile} + * and must be cleared via reflection. + */ + @Before + @After + public void resetFactoryInstance() throws Exception { + Field f = AccessAuthorizerFactory.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } + + @Test + public void getAuthorizer_returnsNoOp_whenRangerDisabled() { + // No RANGER_AUTH_ENABLED property at all → treated as disabled + DrillConfig config = DrillConfig.forClient(); + AccessAuthorizer authorizer = AccessAuthorizerFactory.getAuthorizer(config); + assertTrue("Expected NoOpAccessAuthorizer when ranger disabled", + authorizer instanceof NoOpAccessAuthorizer); + assertFalse(authorizer.isEnabled()); + } + + @Test + public void getAuthorizer_returnsNoOp_whenRangerEnabledFalse() { + Properties props = new Properties(); + props.setProperty(ExecConstants.RANGER_AUTH_ENABLED, "false"); + DrillConfig config = DrillConfig.create(props); + AccessAuthorizer authorizer = AccessAuthorizerFactory.getAuthorizer(config); + assertTrue("Expected NoOpAccessAuthorizer when ranger.enabled=false", + authorizer instanceof NoOpAccessAuthorizer); + assertFalse(authorizer.isEnabled()); + } + + @Test + public void getAuthorizer_returnsCachedInstance() { + DrillConfig config = DrillConfig.forClient(); + AccessAuthorizer first = AccessAuthorizerFactory.getAuthorizer(config); + AccessAuthorizer second = AccessAuthorizerFactory.getAuthorizer(config); + assertSame("Singleton must cache the same instance", first, second); + } + + @Test + public void getAuthorizer_usesDefaultImpl_whenImplKeyAbsent() { + Properties props = new Properties(); + props.setProperty(ExecConstants.RANGER_AUTH_ENABLED, "true"); + DrillConfig config = DrillConfig.create(props); + + // Mock DrillAccessControl so RangerAccessAuthorizer.init() does not attempt + // to actually initialize the Ranger plugin (which would fail without a + // service-def / policy cache on the classpath). + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + mocked.when(() -> DrillAccessControl.isEnabled()).thenReturn(true); + AccessAuthorizer authorizer = AccessAuthorizerFactory.getAuthorizer(config); + assertTrue("Expected default RangerAccessAuthorizer impl", + authorizer instanceof RangerAccessAuthorizer); + assertTrue(authorizer.isEnabled()); + // init("drill") should have been invoked by the factory + mocked.verify(() -> DrillAccessControl.init("drill")); + } + } + + @Test + public void getAuthorizer_usesDefaultServiceName_whenServiceNameAbsent() { + Properties props = new Properties(); + props.setProperty(ExecConstants.RANGER_AUTH_ENABLED, "true"); + // impl explicitly set; service name left to default ("drill") + props.setProperty(ExecConstants.RANGER_AUTHORIZER_IMPL, + "org.apache.drill.exec.security.ranger.RangerAccessAuthorizer"); + DrillConfig config = DrillConfig.create(props); + + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + mocked.when(() -> DrillAccessControl.isEnabled()).thenReturn(true); + AccessAuthorizer authorizer = AccessAuthorizerFactory.getAuthorizer(config); + assertTrue(authorizer instanceof RangerAccessAuthorizer); + mocked.verify(() -> DrillAccessControl.init("drill")); + } + } + + @Test + public void getAuthorizer_usesCustomImpl_whenConfigured() { + Properties props = new Properties(); + props.setProperty(ExecConstants.RANGER_AUTH_ENABLED, "true"); + props.setProperty(ExecConstants.RANGER_AUTHORIZER_IMPL, + "org.apache.drill.exec.security.ranger.RangerAccessAuthorizer"); + props.setProperty(ExecConstants.RANGER_SERVICE_NAME, "myDrillSvc"); + DrillConfig config = DrillConfig.create(props); + + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + mocked.when(() -> DrillAccessControl.isEnabled()).thenReturn(true); + AccessAuthorizer authorizer = AccessAuthorizerFactory.getAuthorizer(config); + assertTrue(authorizer instanceof RangerAccessAuthorizer); + mocked.verify(() -> DrillAccessControl.init("myDrillSvc")); + } + } + + @Test + public void getAuthorizer_throwsRuntimeException_whenImplClassNotFound() { + Properties props = new Properties(); + props.setProperty(ExecConstants.RANGER_AUTH_ENABLED, "true"); + props.setProperty(ExecConstants.RANGER_AUTHORIZER_IMPL, + "org.example.nonexistent.Authorizer"); + DrillConfig config = DrillConfig.create(props); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> AccessAuthorizerFactory.getAuthorizer(config)); + // The factory wraps ClassNotFoundException in RuntimeException + assertTrue(ex.getMessage().contains("Failed to initialize Ranger authorizer")); + } + + @Test + public void getAuthorizer_throwsRuntimeException_whenInitFails() { + Properties props = new Properties(); + props.setProperty(ExecConstants.RANGER_AUTH_ENABLED, "true"); + // Use default impl (RangerAccessAuthorizer); force init() to throw via mock + DrillConfig config = DrillConfig.create(props); + + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + mocked.when(() -> DrillAccessControl.init(anyString())) + .thenThrow(new RuntimeException("init boom")); + RuntimeException ex = assertThrows(RuntimeException.class, + () -> AccessAuthorizerFactory.getAuthorizer(config)); + assertTrue(ex.getMessage().contains("Failed to initialize Ranger authorizer")); + } + } + + /** + * 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 ismisconfigured. + */ + @Test + public void getAuthorizer_factoryReinitializesAfterReset() throws Exception { + DrillConfig disabledConfig = DrillConfig.forClient(); + AccessAuthorizer first = AccessAuthorizerFactory.getAuthorizer(disabledConfig); + assertTrue(first instanceof NoOpAccessAuthorizer); + + // Reset and ask for an enabled config — must NOT return the cached NoOp + resetFactoryInstance(); + Properties props = new Properties(); + props.setProperty(ExecConstants.RANGER_AUTH_ENABLED, "true"); + DrillConfig enabledConfig = DrillConfig.create(props); + + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + mocked.when(() -> DrillAccessControl.isEnabled()).thenReturn(true); + AccessAuthorizer second = AccessAuthorizerFactory.getAuthorizer(enabledConfig); + assertNotSame(first, second); + assertTrue(second instanceof RangerAccessAuthorizer); + } + } +} diff --git a/exec/java-exec/src/test/java/org/apache/drill/exec/security/ranger/NoOpAccessAuthorizerTest.java b/exec/java-exec/src/test/java/org/apache/drill/exec/security/ranger/NoOpAccessAuthorizerTest.java new file mode 100644 index 00000000000..ade5b39e1e9 --- /dev/null +++ b/exec/java-exec/src/test/java/org/apache/drill/exec/security/ranger/NoOpAccessAuthorizerTest.java @@ -0,0 +1,76 @@ +/* + * 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.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.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link NoOpAccessAuthorizer}. + * Verifies the fail-open behavior used when Ranger authorization is disabled + * or unavailable. + */ +public class NoOpAccessAuthorizerTest extends BaseTest { + + private final NoOpAccessAuthorizer authorizer = new NoOpAccessAuthorizer(); + + @Test + public void init_doesNotThrow() { + // init must be a no-op for any input, including null/empty + authorizer.init("drill"); + authorizer.init(""); + authorizer.init(null); + } + + @Test + public void isEnabled_returnsFalse() { + assertFalse(authorizer.isEnabled()); + } + + @Test + public void checkTableAccess_returnsTrue() { + assertTrue(authorizer.checkTableAccess( + "alice", "mysql", "shf", "orders", "SELECT")); + assertTrue(authorizer.checkTableAccess( + "bob", "dfs", "tmp", "foo", "CREATE")); + } + + @Test + public void checkColumnAccess_returnsTrue() { + Set columns = new HashSet<>(Arrays.asList("id", "amount")); + assertTrue(authorizer.checkColumnAccess( + "alice", "mysql", "shf", "orders", columns, "SELECT")); + // Empty column set must still pass (fail-open) + assertTrue(authorizer.checkColumnAccess( + "alice", "mysql", "shf", "orders", Collections.emptySet(), "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/ranger/RangerAccessAuthorizerTest.java b/exec/java-exec/src/test/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizerTest.java new file mode 100644 index 00000000000..da6da5c06ca --- /dev/null +++ b/exec/java-exec/src/test/java/org/apache/drill/exec/security/ranger/RangerAccessAuthorizerTest.java @@ -0,0 +1,162 @@ +/* + * 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.test.BaseTest; +import org.apache.ranger.authorization.drill.authorizer.DrillAccessControl; +import org.apache.ranger.authorization.drill.resource.DrillAccessType; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; + +/** + * Unit tests for {@link RangerAccessAuthorizer}. + * Verifies that all SPI methods delegate to {@link DrillAccessControl} with + * the correct {@link DrillAccessType} (uppercase) and that the private + * {@code toAccessType} helper maps null/unknown access types to {@code SELECT}. + */ +public class RangerAccessAuthorizerTest extends BaseTest { + + private static final String USER = "alice"; + private static final String DS = "mysql"; + private static final String SCHEMA = "shf"; + private static final String TABLE = "orders"; + + @Test + public void init_delegatesToDrillAccessControl() { + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(); + authorizer.init("mySvc"); + mocked.verify(() -> DrillAccessControl.init("mySvc")); + } + } + + @Test + public void isEnabled_delegatesToDrillAccessControl() { + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + mocked.when(DrillAccessControl::isEnabled).thenReturn(true); + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(); + assertTrue(authorizer.isEnabled()); + + mocked.when(DrillAccessControl::isEnabled).thenReturn(false); + assertFalse(authorizer.isEnabled()); + } + } + + @Test + public void checkTableAccess_delegatesWithUppercaseSelect() { + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + mocked.when(() -> DrillAccessControl.checkTableAccess( + anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn(true); + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(); + boolean result = authorizer.checkTableAccess(USER, DS, SCHEMA, TABLE, "select"); + assertTrue(result); + // accessType is normalized to upper-case enum value + mocked.verify(() -> DrillAccessControl.checkTableAccess( + eq(USER), eq(DS), eq(SCHEMA), eq(TABLE), eq(DrillAccessType.SELECT))); + } + } + + @Test + public void checkTableAccess_delegatesWithNullAccessType_defaultsToSelect() { + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + mocked.when(() -> DrillAccessControl.checkTableAccess( + anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn(true); + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(); + boolean result = authorizer.checkTableAccess(USER, DS, SCHEMA, TABLE, null); + assertTrue(result); + mocked.verify(() -> DrillAccessControl.checkTableAccess( + eq(USER), eq(DS), eq(SCHEMA), eq(TABLE), eq(DrillAccessType.SELECT))); + } + } + + @Test + public void checkTableAccess_delegatesWithUnknownAccessType_defaultsToSelect() { + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + mocked.when(() -> DrillAccessControl.checkTableAccess( + anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn(true); + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(); + // "FOO" is not a valid DrillAccessType — must fall back to SELECT + boolean result = authorizer.checkTableAccess(USER, DS, SCHEMA, TABLE, "FOO"); + assertTrue(result); + mocked.verify(() -> DrillAccessControl.checkTableAccess( + eq(USER), eq(DS), eq(SCHEMA), eq(TABLE), eq(DrillAccessType.SELECT))); + } + } + + @Test + public void checkTableAccess_delegatesWithCreateAccessType() { + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + mocked.when(() -> DrillAccessControl.checkTableAccess( + anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn(true); + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(); + boolean result = authorizer.checkTableAccess(USER, DS, SCHEMA, TABLE, "create"); + assertTrue(result); + mocked.verify(() -> DrillAccessControl.checkTableAccess( + eq(USER), eq(DS), eq(SCHEMA), eq(TABLE), eq(DrillAccessType.CREATE))); + } + } + + @Test + public void checkColumnAccess_delegatesPerColumn() { + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + mocked.when(() -> DrillAccessControl.checkColumnAccess( + anyString(), anyString(), anyString(), anyString(), any(), any())) + .thenReturn(true); + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(); + Set columns = new HashSet<>(Arrays.asList("id", "amount")); + boolean result = authorizer.checkColumnAccess( + USER, DS, SCHEMA, TABLE, columns, "select"); + assertTrue(result); + mocked.verify(() -> DrillAccessControl.checkColumnAccess( + eq(USER), eq(DS), eq(SCHEMA), eq(TABLE), + eq(columns), eq(DrillAccessType.SELECT))); + } + } + + @Test + public void checkColumnAccess_withNullAccessType_defaultsToSelect() { + try (MockedStatic mocked = mockStatic(DrillAccessControl.class)) { + mocked.when(() -> DrillAccessControl.checkColumnAccess( + anyString(), anyString(), anyString(), anyString(), any(), any())) + .thenReturn(true); + RangerAccessAuthorizer authorizer = new RangerAccessAuthorizer(); + Set columns = new HashSet<>(Arrays.asList("id")); + boolean result = authorizer.checkColumnAccess( + USER, DS, SCHEMA, TABLE, columns, null); + assertTrue(result); + mocked.verify(() -> DrillAccessControl.checkColumnAccess( + eq(USER), eq(DS), eq(SCHEMA), eq(TABLE), + eq(columns), eq(DrillAccessType.SELECT))); + } + } +} diff --git a/pom.xml b/pom.xml index 1a107272670..cbc84f0bb36 100644 --- a/pom.xml +++ b/pom.xml @@ -3039,5 +3039,6 @@ drill-yarn distribution metastore + drill-ranger