diff --git a/distribution/pom.xml b/distribution/pom.xml
index 38ac0e6e3c09..a3f34a6825e9 100644
--- a/distribution/pom.xml
+++ b/distribution/pom.xml
@@ -448,6 +448,8 @@
-corg.apache.druid.extensions.contrib:druid-rabbit-indexing-service-c
+ org.apache.druid.extensions.contrib:druid-opa-authorizer
+ -corg.apache.druid.extensions.contrib:grpc-query-corg.apache.druid.extensions.contrib:druid-ranger-security
diff --git a/docs/configuration/extensions.md b/docs/configuration/extensions.md
index 2ad0ad180b2a..929e4700acd1 100644
--- a/docs/configuration/extensions.md
+++ b/docs/configuration/extensions.md
@@ -105,6 +105,7 @@ All of these community extensions can be downloaded using [pull-deps](../operati
|druid-spectator-histogram|Support for efficient approximate percentile queries|[link](../development/extensions-contrib/spectator-histogram.md)|
|druid-rabbit-indexing-service|Support for creating and managing [RabbitMQ](https://www.rabbitmq.com/) indexing tasks|[link](../development/extensions-contrib/rabbit-stream-ingestion.md)|
|druid-ranger-security|Support for access control through Apache Ranger.|[link](../development/extensions-contrib/druid-ranger-security.md)|
+|druid-opa-authorizer|Support for access control through Open Policy Agent (OPA).|[link](../development/extensions-contrib/druid-opa-authorizer.md)|
## Promoting community extensions to core extensions
diff --git a/docs/development/extensions-contrib/druid-opa-authorizer.md b/docs/development/extensions-contrib/druid-opa-authorizer.md
new file mode 100644
index 000000000000..882250fb7854
--- /dev/null
+++ b/docs/development/extensions-contrib/druid-opa-authorizer.md
@@ -0,0 +1,190 @@
+---
+id: druid-opa-authorizer
+title: "Open Policy Agent (OPA) Authorizer"
+---
+
+
+
+To use this Apache Druid extension, [include](../../configuration/extensions.md#loading-extensions) `druid-opa-authorizer` in the extensions load list.
+
+This extension requests policy decisions from [Open Policy Agent](https://www.openpolicyagent.org/) (OPA).
+
+## Configuration
+
+The OPA authorizer needs to be referenced in your authenticator. The OPA authorizer is configured like so:
+
+|Property|Description|Default|Required|
+|--------|-----------|-------|--------|
+|`druid.auth.authorizer..type`|Must be `opa`.|none|yes|
+|`druid.auth.authorizer..opaUri`|The URI for the OPA server (e.g. `http://:/v1/data/my/druid/allow`).|none|yes|
+|`druid.auth.authorizer..timeoutMs`|Timeout in milliseconds for OPA HTTP requests. Applies to both connection and request timeout. If OPA does not respond within this duration, authorization is denied.|5000|no|
+
+## Write Rego rules
+
+The authorizer will send a request to the `opaUri` specified in the configuration. The input will be:
+
+```json
+{
+ "authenticationResult": {
+ "identity": "username",
+ "authorizerName": "authorizerName",
+ "authenticatedBy": "authenticatorName",
+ "context": null
+ },
+ "action": "READ|WRITE",
+ "resource": {
+ "name": "resourceName",
+ "type": "resourceType"
+ }
+}
+```
+
+For the details, especially the resources types, consult the Druid documentation on the [Authentication and Authorization Model](../../operations/security-user-auth.md#authentication-and-authorization-model).
+
+Inside your Rego rules, this snippet of data will be available as `input`.
+For the details on how to write Rego rules, have a look at the [OPA documentation](https://www.openpolicyagent.org/docs/latest/).
+
+## Example: Setting up OPA locally to test
+
+The `druid-opa-authorizer` extension source contains an [example](https://github.com/apache/druid/tree/{{DRUIDVERSION}}/extensions-contrib/druid-opa-authorizer/example/) directory with some test files.
+
+### Run a local OPA server with example files
+
+Download the [OPA binary](https://www.openpolicyagent.org/docs/latest/#running-opa) into the `extensions-contrib/druid-opa-authorizer/example/` directory and follow the instructions (setting execute permissions).
+
+Start the server with the provided example files: `./opa run -s druid.rego druid.json`.
+By default, the server will then run on port `8181`.
+
+The example files define 6 users and 4 roles, with rules that work based on the roles.
+There is a fifth special `admin` role that grants full access to everything.
+
+### Configure Druid
+
+In Druid, in your common `runtime.properties`:
+- Add `druid-opa-authorizer` and `druid-basic-security` extensions to the load list:
+```properties
+druid.extensions.loadList=[..., "druid-opa-authorizer", "druid-basic-security"]
+```
+- Add the following entries:
+```properties
+# Druid basic security
+druid.auth.authenticatorChain=["basicAuthenticator"]
+druid.auth.authenticator.basicAuthenticator.type=basic
+
+# Default password for 'admin' user, should be changed for production.
+druid.auth.authenticator.basicAuthenticator.initialAdminPassword=password1
+
+# Default password for internal 'druid_system' user, should be changed for production.
+druid.auth.authenticator.basicAuthenticator.initialInternalClientPassword=password2
+
+# Uses the metadata store for storing users, you can use authentication API to create new users and grant permissions
+druid.auth.authenticator.basicAuthenticator.credentialsValidator.type=metadata
+
+# If true and the request credential doesn't exist in this credentials store, the request will proceed to next Authenticator in the chain.
+druid.auth.authenticator.basicAuthenticator.skipOnFailure=false
+druid.auth.authenticator.basicAuthenticator.authorizerName=opaAuthorizer
+
+# Escalator
+druid.escalator.type=basic
+druid.escalator.internalClientUsername=druid_system
+druid.escalator.internalClientPassword=password2
+druid.escalator.authorizerName=opaAuthorizer
+
+druid.auth.authorizers=["opaAuthorizer"]
+druid.auth.authorizer.opaAuthorizer.type=opa
+druid.auth.authorizer.opaAuthorizer.opaUri=http://localhost:8181/v1/data/app/druid/allow
+```
+
+### Setup Users and Verify
+
+Run the `setup.sh` script in the `extensions-contrib/druid-opa-authorizer/example/setup/` directory to create the example users.
+Five users will be created: alice, bob, christy, dylan, and eve. The password for each user is the same as their username.
+
+When connecting to the dashboard you will now be prompted to log in.
+- If you log in with alice (admin), you will be able to access everything.
+- If you log in with eve (no access grants) you should see 403 errors inside the Druid Console.
+- If you log in with christy (Datasource and State read grants), you should be able to see the Services tab in the Druid Console, but not the cluster configs.
+
+
+## Troubleshooting
+
+If you get 401/403 type errors, check the OPA logs (you might need to enable debug level).
+
+If you get 500 type errors, it might be that the internal `druid_system` user doesn't have full permissions.
+
+You can increase log output for the authorizer by adding this snippet to your `log4j2.xml`:
+
+```xml
+
+
+
+```
+
+## Example: Testing with LDAP Authentication
+
+To test LDAP authentication with the OPA authorizer, you can use the provided LDAP example files in the `extensions-contrib/druid-opa-authorizer/example/ldap/` directory.
+
+### Run a local LDAP server
+
+Navigate to the `extensions-contrib/druid-opa-authorizer/example/ldap/` directory and run the `run-ldap.sh` script to start a mock LDAP server:
+
+```bash
+./run-ldap.sh
+```
+
+This will start an OpenLDAP server on port `8389` with the users `alice`, `bob`, `christy`, `dylan`, `eve`, and `druid_system`.
+
+### Configure Druid for LDAP and OPA
+
+In your common `runtime.properties`, replace the `basicAuthenticator` configuration with the following:
+
+```properties
+# Druid basic security with LDAP
+druid.auth.authenticatorChain=["ldapAuthenticator"]
+druid.auth.authenticator.ldapAuthenticator.type=basic
+
+# LDAP Validator Configuration
+druid.auth.authenticator.ldapAuthenticator.credentialsValidator.type=ldap
+druid.auth.authenticator.ldapAuthenticator.credentialsValidator.url=ldap://localhost:8389
+druid.auth.authenticator.ldapAuthenticator.credentialsValidator.bindUser=cn=admin,dc=example,dc=org
+druid.auth.authenticator.ldapAuthenticator.credentialsValidator.bindPassword=admin
+druid.auth.authenticator.ldapAuthenticator.credentialsValidator.baseDn=ou=Users,dc=example,dc=org
+druid.auth.authenticator.ldapAuthenticator.credentialsValidator.userSearch=(&(uid=%s)(objectClass=inetOrgPerson))
+druid.auth.authenticator.ldapAuthenticator.credentialsValidator.userAttribute=uid
+
+# Redirect to OPA authorizer
+druid.auth.authenticator.ldapAuthenticator.authorizerName=opaAuthorizer
+
+# Escalator using LDAP system user
+druid.escalator.type=basic
+druid.escalator.internalClientUsername=druid_system
+druid.escalator.internalClientPassword=password2
+druid.escalator.authorizerName=opaAuthorizer
+
+# OPA Authorizer
+druid.auth.authorizers=["opaAuthorizer"]
+druid.auth.authorizer.opaAuthorizer.type=opa
+druid.auth.authorizer.opaAuthorizer.opaUri=http://localhost:8181/v1/data/app/druid/allow
+```
+
+### Verify
+
+Log in to the Druid Console as `alice` with password `alice`. OPA will receive the identity `alice` and authorize based on the roles defined in your OPA policy.
+
diff --git a/embedded-tests/pom.xml b/embedded-tests/pom.xml
index f16440109f95..4a6d7cf007c7 100644
--- a/embedded-tests/pom.xml
+++ b/embedded-tests/pom.xml
@@ -269,6 +269,12 @@
${project.parent.version}test
+
+ org.apache.druid.extensions.contrib
+ druid-opa-authorizer
+ ${project.parent.version}
+ test
+ org.apache.druid.extensionsdruid-basic-security
diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/opa/OpaBasicAuthConfigurationDockerTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/opa/OpaBasicAuthConfigurationDockerTest.java
new file mode 100644
index 000000000000..350cdf4b291d
--- /dev/null
+++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/opa/OpaBasicAuthConfigurationDockerTest.java
@@ -0,0 +1,148 @@
+/*
+ * 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.druid.testing.embedded.opa;
+
+import org.apache.druid.java.util.http.client.HttpClient;
+import org.apache.druid.java.util.http.client.response.StatusResponseHolder;
+import org.apache.druid.security.basic.authentication.entity.BasicAuthenticatorCredentialUpdate;
+import org.apache.druid.server.security.Access;
+import org.apache.druid.testing.embedded.EmbeddedResource;
+import org.apache.druid.testing.embedded.auth.AbstractAuthConfigurationTest;
+import org.apache.druid.testing.embedded.auth.HttpUtil;
+import org.jboss.netty.handler.codec.http.HttpMethod;
+import org.jboss.netty.handler.codec.http.HttpResponseStatus;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Tag;
+
+import java.util.Properties;
+
+@Tag("docker-test")
+public class OpaBasicAuthConfigurationDockerTest extends AbstractAuthConfigurationTest
+{
+ private static final String AUTHENTICATOR_NAME = "basic";
+ private static final String AUTHORIZER_NAME = "opaauth";
+
+ private static final String EXPECTED_AVATICA_AUTH_ERROR = "Error while executing SQL \"SELECT * FROM INFORMATION_SCHEMA.COLUMNS\": Remote driver error: " + Access.DEFAULT_ERROR_MESSAGE;
+ private static final String EXPECTED_AVATICA_AUTHZ_ERROR = "Error while executing SQL \"SELECT * FROM INFORMATION_SCHEMA.COLUMNS\": Remote driver error: " + Access.DEFAULT_ERROR_MESSAGE;
+
+ @Override
+ protected void setupDatasourceOnlyUser()
+ {
+ createUser("datasourceOnlyUser", "helloworld");
+ }
+
+ @Override
+ protected void setupDatasourceAndContextParamsUser()
+ {
+ createUser("datasourceAndContextParamsUser", "helloworld");
+ }
+
+ @Override
+ protected void setupDatasourceAndSysTableUser()
+ {
+ createUser("datasourceAndSysUser", "helloworld");
+ }
+
+ @Override
+ protected void setupDatasourceAndSysAndStateUser()
+ {
+ createUser("datasourceWithStateUser", "helloworld");
+ }
+
+ @Override
+ protected void setupSysTableAndStateOnlyUser()
+ {
+ createUser("stateOnlyUser", "helloworld");
+ }
+
+ @Override
+ protected void setupTestSpecificHttpClients()
+ {
+ // No test specific clients needed for this basic happy path.
+ }
+
+ @Override
+ protected String getAuthenticatorName()
+ {
+ return AUTHENTICATOR_NAME;
+ }
+
+ @Override
+ protected String getAuthorizerName()
+ {
+ return AUTHORIZER_NAME;
+ }
+
+ @Override
+ protected String getExpectedAvaticaAuthError()
+ {
+ return EXPECTED_AVATICA_AUTH_ERROR;
+ }
+
+ @Override
+ protected String getExpectedAvaticaAuthzError()
+ {
+ return EXPECTED_AVATICA_AUTHZ_ERROR;
+ }
+
+ @Override
+ protected void checkLoadStatusSingle(HttpClient httpClient, String baseUrl) throws Exception
+ {
+ StatusResponseHolder holder = HttpUtil.makeRequest(
+ httpClient,
+ HttpMethod.GET,
+ baseUrl + "/druid-ext/basic-security/authentication/loadStatus"
+ );
+ String content = holder.getContent();
+ Assertions.assertTrue(content.contains("\"" + AUTHENTICATOR_NAME + "\":true"));
+ // OPA authorizer does not register with basic-security authorization loadStatus endpoint
+ }
+
+ @Override
+ protected EmbeddedResource getAuthResource()
+ {
+ return new OpaBasicAuthResource();
+ }
+
+ @Override
+ protected Properties getAvaticaConnectionPropertiesForInvalidAdmin()
+ {
+ Properties properties = new Properties();
+ properties.setProperty("user", "admin");
+ properties.setProperty("password", "invalid_password");
+ return properties;
+ }
+
+ @Override
+ protected Properties getAvaticaConnectionPropertiesForUser(User user)
+ {
+ Properties properties = new Properties();
+ properties.setProperty("user", user.getName());
+ properties.setProperty("password", user.getPassword());
+ return properties;
+ }
+
+ private void createUser(String username, String password)
+ {
+ String baseUrl = getCoordinatorUrl() + "/druid-ext/basic-security/authentication/db/basic/users/" + username;
+ HttpUtil.makeRequest(getHttpClient(User.ADMIN), HttpMethod.POST, baseUrl, null, HttpResponseStatus.OK);
+ HttpUtil.makeRequest(getHttpClient(User.ADMIN), HttpMethod.POST, baseUrl + "/credentials", new BasicAuthenticatorCredentialUpdate(password, 5000), HttpResponseStatus.OK);
+ }
+}
diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/opa/OpaBasicAuthResource.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/opa/OpaBasicAuthResource.java
new file mode 100644
index 000000000000..8bcba6818e4a
--- /dev/null
+++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/opa/OpaBasicAuthResource.java
@@ -0,0 +1,107 @@
+/*
+ * 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.druid.testing.embedded.opa;
+
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.security.basic.BasicSecurityDruidModule;
+import org.apache.druid.security.opa.OpaDruidModule;
+import org.apache.druid.testing.embedded.EmbeddedDruidCluster;
+import org.apache.druid.testing.embedded.EmbeddedResource;
+import org.apache.druid.testing.embedded.indexing.Resources;
+import org.testcontainers.containers.BindMode;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import java.util.List;
+
+public class OpaBasicAuthResource implements EmbeddedResource
+{
+ private static final String OPA_IMAGE = "openpolicyagent/opa:1.18.2-debug";
+
+ public static final String ADMIN_PASSWORD = "priest";
+ public static final String SYSTEM_PASSWORD = "warlock";
+ public static final String SYSTEM_USER = "druid_system";
+
+ private static final String AUTHENTICATOR_NAME = "basic";
+ private static final String AUTHORIZER_NAME = "opaauth";
+
+ private final GenericContainer> opaContainer;
+
+ public OpaBasicAuthResource()
+ {
+ opaContainer = new GenericContainer<>(DockerImageName.parse(OPA_IMAGE))
+ .withFileSystemBind(
+ Resources.getFileForResource("opa-configs").getAbsolutePath(),
+ "/etc/opa",
+ BindMode.READ_ONLY
+ )
+ .withExposedPorts(8181)
+ .withCommand("run", "--server", "--addr", "0.0.0.0:8181", "--log-level", "debug", "/etc/opa/druid.rego", "/etc/opa/druid.json");
+ opaContainer.setPortBindings(List.of("8181:8181"));
+ }
+
+ @Override
+ public void start()
+ {
+ opaContainer.start();
+ }
+
+ @Override
+ public void stop()
+ {
+ opaContainer.stop();
+ }
+
+ @Override
+ public void onStarted(EmbeddedDruidCluster cluster)
+ {
+ cluster
+ .addExtensions(BasicSecurityDruidModule.class, OpaDruidModule.class)
+ .addCommonProperty("druid.auth.authenticatorChain", StringUtils.format("[\"%s\"]", AUTHENTICATOR_NAME))
+ .addCommonProperty(authenticatorProp("type"), "basic")
+ .addCommonProperty(authenticatorProp("initialAdminPassword"), ADMIN_PASSWORD)
+ .addCommonProperty(authenticatorProp("initialInternalClientPassword"), SYSTEM_PASSWORD)
+ .addCommonProperty(authenticatorProp("authorizerName"), AUTHORIZER_NAME)
+
+ .addCommonProperty("druid.auth.authorizers", StringUtils.format("[\"%s\"]", AUTHORIZER_NAME))
+ .addCommonProperty(authorizerProp("type"), "opa")
+ .addCommonProperty(authorizerProp("opaUri"), "http://localhost:8181/v1/data/app/druid/allow")
+
+ .addCommonProperty(escalatorProp("type"), "basic")
+ .addCommonProperty(escalatorProp("internalClientPassword"), SYSTEM_PASSWORD)
+ .addCommonProperty(escalatorProp("internalClientUsername"), SYSTEM_USER)
+ .addCommonProperty(escalatorProp("authorizerName"), AUTHORIZER_NAME);
+ }
+
+ private String escalatorProp(String name)
+ {
+ return StringUtils.format("druid.escalator.%s", name);
+ }
+
+ private String authorizerProp(String name)
+ {
+ return StringUtils.format("druid.auth.authorizer.%s.%s", AUTHORIZER_NAME, name);
+ }
+
+ private String authenticatorProp(String name)
+ {
+ return StringUtils.format("druid.auth.authenticator.%s.%s", AUTHENTICATOR_NAME, name);
+ }
+}
diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/opa/OpaLdapAuthConfigurationDockerTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/opa/OpaLdapAuthConfigurationDockerTest.java
new file mode 100644
index 000000000000..240187ae5780
--- /dev/null
+++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/opa/OpaLdapAuthConfigurationDockerTest.java
@@ -0,0 +1,139 @@
+/*
+ * 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.druid.testing.embedded.opa;
+
+import org.apache.druid.java.util.http.client.HttpClient;
+import org.apache.druid.java.util.http.client.response.StatusResponseHolder;
+import org.apache.druid.server.security.Access;
+import org.apache.druid.testing.embedded.EmbeddedResource;
+import org.apache.druid.testing.embedded.auth.AbstractAuthConfigurationTest;
+import org.apache.druid.testing.embedded.auth.HttpUtil;
+import org.jboss.netty.handler.codec.http.HttpMethod;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Tag;
+
+import java.util.Properties;
+
+@Tag("docker-test")
+public class OpaLdapAuthConfigurationDockerTest extends AbstractAuthConfigurationTest
+{
+ private static final String AUTHENTICATOR_NAME = "ldap";
+ private static final String AUTHORIZER_NAME = "opaauth";
+
+ private static final String EXPECTED_AVATICA_AUTH_ERROR = "Error while executing SQL \"SELECT * FROM INFORMATION_SCHEMA.COLUMNS\": Remote driver error: " + Access.DEFAULT_ERROR_MESSAGE;
+ private static final String EXPECTED_AVATICA_AUTHZ_ERROR = "Error while executing SQL \"SELECT * FROM INFORMATION_SCHEMA.COLUMNS\": Remote driver error: " + Access.DEFAULT_ERROR_MESSAGE;
+
+ @Override
+ protected void setupDatasourceOnlyUser()
+ {
+ // LDAP already has these users, and OPA has the role mapping.
+ }
+
+ @Override
+ protected void setupDatasourceAndContextParamsUser()
+ {
+ // LDAP already has these users, and OPA has the role mapping.
+ }
+
+ @Override
+ protected void setupDatasourceAndSysTableUser()
+ {
+ // LDAP already has these users, and OPA has the role mapping.
+ }
+
+ @Override
+ protected void setupDatasourceAndSysAndStateUser()
+ {
+ // LDAP already has these users, and OPA has the role mapping.
+ }
+
+ @Override
+ protected void setupSysTableAndStateOnlyUser()
+ {
+ // LDAP already has these users, and OPA has the role mapping.
+ }
+
+ @Override
+ protected void setupTestSpecificHttpClients()
+ {
+ // No test specific clients needed for this basic happy path.
+ }
+
+ @Override
+ protected String getAuthenticatorName()
+ {
+ return AUTHENTICATOR_NAME;
+ }
+
+ @Override
+ protected String getAuthorizerName()
+ {
+ return AUTHORIZER_NAME;
+ }
+
+ @Override
+ protected String getExpectedAvaticaAuthError()
+ {
+ return EXPECTED_AVATICA_AUTH_ERROR;
+ }
+
+ @Override
+ protected String getExpectedAvaticaAuthzError()
+ {
+ return EXPECTED_AVATICA_AUTHZ_ERROR;
+ }
+
+ @Override
+ protected void checkLoadStatusSingle(HttpClient httpClient, String baseUrl) throws Exception
+ {
+ StatusResponseHolder holder = HttpUtil.makeRequest(
+ httpClient,
+ HttpMethod.GET,
+ baseUrl + "/druid-ext/basic-security/authentication/loadStatus"
+ );
+ String content = holder.getContent();
+ Assertions.assertTrue(content.contains("\"" + AUTHENTICATOR_NAME + "\":true"));
+ // OPA authorizer does not register with basic-security authorization loadStatus endpoint
+ }
+
+ @Override
+ protected EmbeddedResource getAuthResource()
+ {
+ return new OpaLdapAuthResource();
+ }
+
+ @Override
+ protected Properties getAvaticaConnectionPropertiesForInvalidAdmin()
+ {
+ Properties properties = new Properties();
+ properties.setProperty("user", "admin");
+ properties.setProperty("password", "invalid_password");
+ return properties;
+ }
+
+ @Override
+ protected Properties getAvaticaConnectionPropertiesForUser(User user)
+ {
+ Properties properties = new Properties();
+ properties.setProperty("user", user.getName());
+ properties.setProperty("password", user.getPassword());
+ return properties;
+ }
+}
diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/opa/OpaLdapAuthResource.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/opa/OpaLdapAuthResource.java
new file mode 100644
index 000000000000..46094d52c07f
--- /dev/null
+++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/opa/OpaLdapAuthResource.java
@@ -0,0 +1,131 @@
+/*
+ * 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.druid.testing.embedded.opa;
+
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.security.basic.BasicSecurityDruidModule;
+import org.apache.druid.security.opa.OpaDruidModule;
+import org.apache.druid.testing.embedded.EmbeddedDruidCluster;
+import org.apache.druid.testing.embedded.EmbeddedResource;
+import org.apache.druid.testing.embedded.indexing.Resources;
+import org.testcontainers.containers.BindMode;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import java.util.List;
+
+public class OpaLdapAuthResource implements EmbeddedResource
+{
+ private static final String LDAP_IMAGE = "osixia/openldap:1.5.0";
+ private static final String OPA_IMAGE = "openpolicyagent/opa:1.18.2-debug";
+
+ public static final String ADMIN_PASSWORD = "priest";
+ public static final String SYSTEM_PASSWORD = "warlock";
+ public static final String SYSTEM_USER = "druid_system";
+
+ private static final String AUTHENTICATOR_NAME = "ldap";
+ private static final String AUTHORIZER_NAME = "opaauth";
+
+ private final GenericContainer> ldapContainer;
+ private final GenericContainer> opaContainer;
+
+ public OpaLdapAuthResource()
+ {
+ ldapContainer = new GenericContainer<>(DockerImageName.parse(LDAP_IMAGE))
+ .withFileSystemBind(
+ Resources.getFileForResource("ldap-configs").getAbsolutePath(),
+ "/container/service/slapd/assets/config/bootstrap/ldif/custom",
+ BindMode.READ_WRITE
+ )
+ .withExposedPorts(389, 636)
+ .withCommand("--copy-service");
+ ldapContainer.setPortBindings(List.of("8389:389", "8636:636"));
+
+ opaContainer = new GenericContainer<>(DockerImageName.parse(OPA_IMAGE))
+ .withFileSystemBind(
+ Resources.getFileForResource("opa-configs").getAbsolutePath(),
+ "/etc/opa",
+ BindMode.READ_ONLY
+ )
+ .withExposedPorts(8181)
+ .withCommand("run", "--server", "--addr", "0.0.0.0:8181", "--log-level", "debug", "/etc/opa/druid.rego", "/etc/opa/druid.json");
+ opaContainer.setPortBindings(List.of("8181:8181"));
+ }
+
+ @Override
+ public void start()
+ {
+ ldapContainer.start();
+ opaContainer.start();
+ }
+
+ @Override
+ public void stop()
+ {
+ opaContainer.stop();
+ ldapContainer.stop();
+ }
+
+ @Override
+ public void onStarted(EmbeddedDruidCluster cluster)
+ {
+ cluster
+ .addExtensions(BasicSecurityDruidModule.class, OpaDruidModule.class)
+ .addCommonProperty(authenticatorProp("authorizerName"), AUTHORIZER_NAME)
+ .addCommonProperty(authenticatorProp("initialAdminPassword"), ADMIN_PASSWORD)
+ .addCommonProperty(authenticatorProp("initialInternalClientPassword"), SYSTEM_PASSWORD)
+ .addCommonProperty(authenticatorProp("type"), "basic")
+ .addCommonProperty(authenticatorProp("credentialsValidator.type"), "ldap")
+ .addCommonProperty(authenticatorProp("credentialsValidator.url"), "ldap://localhost:8389")
+ .addCommonProperty(authenticatorProp("credentialsValidator.bindUser"), "cn=admin,dc=example,dc=org")
+ .addCommonProperty(authenticatorProp("credentialsValidator.bindPassword"), "admin")
+ .addCommonProperty(authenticatorProp("credentialsValidator.baseDn"), "ou=Users,dc=example,dc=org")
+ .addCommonProperty(
+ authenticatorProp("credentialsValidator.userSearch"),
+ "(&(uid=%s)(objectClass=inetOrgPerson))"
+ )
+ .addCommonProperty(authenticatorProp("credentialsValidator.userAttribute"), "uid")
+ .addCommonProperty("druid.auth.authenticatorChain", "[\"ldap\"]")
+
+ .addCommonProperty("druid.auth.authorizers", StringUtils.format("[\"%s\"]", AUTHORIZER_NAME))
+ .addCommonProperty(authorizerProp("type"), "opa")
+ .addCommonProperty(authorizerProp("opaUri"), "http://localhost:8181/v1/data/app/druid/allow")
+
+ .addCommonProperty(escalatorProp("type"), "basic")
+ .addCommonProperty(escalatorProp("internalClientPassword"), SYSTEM_PASSWORD)
+ .addCommonProperty(escalatorProp("internalClientUsername"), SYSTEM_USER)
+ .addCommonProperty(escalatorProp("authorizerName"), AUTHORIZER_NAME);
+ }
+
+ private String escalatorProp(String name)
+ {
+ return StringUtils.format("druid.escalator.%s", name);
+ }
+
+ private String authorizerProp(String name)
+ {
+ return StringUtils.format("druid.auth.authorizer.%s.%s", AUTHORIZER_NAME, name);
+ }
+
+ private String authenticatorProp(String name)
+ {
+ return StringUtils.format("druid.auth.authenticator.%s.%s", AUTHENTICATOR_NAME, name);
+ }
+}
diff --git a/embedded-tests/src/test/resources/opa-configs/druid.json b/embedded-tests/src/test/resources/opa-configs/druid.json
new file mode 100644
index 000000000000..d8ed29b55d40
--- /dev/null
+++ b/embedded-tests/src/test/resources/opa-configs/druid.json
@@ -0,0 +1,41 @@
+{
+ "user_roles": {
+ "admin": ["admin"],
+ "druid_system": ["admin"],
+ "datasourceOnlyUser": ["datasource_only"],
+ "datasourceAndContextParamsUser": ["datasource_and_context"],
+ "datasourceAndSysUser": ["datasource_and_sys"],
+ "datasourceWithStateUser": ["datasource_with_state"],
+ "stateOnlyUser": ["state_only"]
+ },
+ "role_grants": {
+ "admin": [
+ { "action": "READ", "resource": { "type": ".*", "name": ".*" } },
+ { "action": "WRITE", "resource": { "type": ".*", "name": ".*" } }
+ ],
+ "datasource_only": [
+ { "action": "READ", "resource": { "type": "DATASOURCE", "name": "auth_test" } }
+ ],
+ "datasource_and_context": [
+ { "action": "READ", "resource": { "type": "DATASOURCE", "name": "auth_test" } },
+ { "action": "WRITE", "resource": { "type": "QUERY_CONTEXT", "name": "engine" } },
+ { "action": "WRITE", "resource": { "type": "QUERY_CONTEXT", "name": "auth_test_ctx" } }
+ ],
+ "datasource_and_sys": [
+ { "action": "READ", "resource": { "type": "DATASOURCE", "name": "auth_test" } },
+ { "action": "READ", "resource": { "type": "SYSTEM_TABLE", "name": "segments" } },
+ { "action": "READ", "resource": { "type": "SYSTEM_TABLE", "name": "servers" } },
+ { "action": "READ", "resource": { "type": "SYSTEM_TABLE", "name": "server_segments" } },
+ { "action": "READ", "resource": { "type": "SYSTEM_TABLE", "name": "tasks" } }
+ ],
+ "datasource_with_state": [
+ { "action": "READ", "resource": { "type": "DATASOURCE", "name": "auth_test" } },
+ { "action": "READ", "resource": { "type": "SYSTEM_TABLE", "name": ".*" } },
+ { "action": "READ", "resource": { "type": "STATE", "name": ".*" } }
+ ],
+ "state_only": [
+ { "action": "READ", "resource": { "type": "STATE", "name": ".*" } },
+ { "action": "READ", "resource": { "type": "SYSTEM_TABLE", "name": ".*" } }
+ ]
+ }
+}
diff --git a/embedded-tests/src/test/resources/opa-configs/druid.rego b/embedded-tests/src/test/resources/opa-configs/druid.rego
new file mode 100644
index 000000000000..98f9b155fae8
--- /dev/null
+++ b/embedded-tests/src/test/resources/opa-configs/druid.rego
@@ -0,0 +1,20 @@
+package app.druid
+import rego.v1
+default allow := false
+allow if {
+ user_is_admin
+}
+allow if {
+ some grant
+ user_is_granted[grant]
+ input.action == grant.action
+ regex.match(sprintf("^%s$", [grant.resource.name]), input.resource.name)
+ regex.match(sprintf("^%s$", [grant.resource.type]), input.resource.type)
+}
+user_is_admin if {
+ "admin" in data.user_roles[input.authenticationResult.identity]
+}
+user_is_granted contains grant if {
+ some role in data.user_roles[input.authenticationResult.identity]
+ some grant in data.role_grants[role]
+}
diff --git a/extensions-contrib/druid-opa-authorizer/README.md b/extensions-contrib/druid-opa-authorizer/README.md
new file mode 100644
index 000000000000..30e931b966dc
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/README.md
@@ -0,0 +1,20 @@
+
+
+See [docs/development/extensions-contrib/druid-opa-authorizer.md](../../docs/development/extensions-contrib/druid-opa-authorizer.md) for more details.
diff --git a/extensions-contrib/druid-opa-authorizer/example/druid.json b/extensions-contrib/druid-opa-authorizer/example/druid.json
new file mode 100644
index 000000000000..aac2c9a4753b
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/example/druid.json
@@ -0,0 +1,79 @@
+{
+ "user_roles": {
+ "admin": [
+ "admin"
+ ],
+ "alice": [
+ "admin"
+ ],
+ "druid_system": [
+ "admin"
+ ],
+ "bob": [
+ "data_science"
+ ],
+ "christy": [
+ "data_science",
+ "data_manager"
+ ],
+ "dylan": [
+ "data_science",
+ "cluster_admin"
+ ],
+ "eve": [
+ "external"
+ ]
+ },
+ "role_grants": {
+ "data_science": [
+ {
+ "action": "READ",
+ "resource": {
+ "type": "DATASOURCE",
+ "name": ".*"
+ }
+ },
+ {
+ "action": "READ",
+ "resource": {
+ "type": "STATE",
+ "name": "STATE"
+ }
+ }
+ ],
+ "data_manager": [
+ {
+ "action": "WRITE",
+ "resource": {
+ "type": "DATASOURCE",
+ "name": ".*"
+ }
+ }
+ ],
+ "cluster_admin": [
+ {
+ "action": "READ",
+ "resource": {
+ "type": "CONFIG",
+ "name": "CONFIG"
+ }
+ },
+ {
+ "action": "WRITE",
+ "resource": {
+ "type": "CONFIG",
+ "name": "CONFIG"
+ }
+ }
+ ],
+ "external": [
+ {
+ "action": "READ",
+ "resource": {
+ "type": "DATASOURCE",
+ "name": "ReportTable"
+ }
+ }
+ ]
+ }
+}
diff --git a/extensions-contrib/druid-opa-authorizer/example/druid.rego b/extensions-contrib/druid-opa-authorizer/example/druid.rego
new file mode 100644
index 000000000000..9c08a5bf238a
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/example/druid.rego
@@ -0,0 +1,54 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+package app.druid
+
+import rego.v1
+
+# By default, deny requests.
+default allow := false
+
+# Allow admins to do anything.
+allow if {
+ user_is_admin
+}
+
+# Allow the action if the user is granted permission to perform the action.
+allow if {
+ # Find grants for the user.
+ some grant
+ user_is_granted[grant]
+
+ # Check if the grant permits the action.
+ input.action == grant.action
+ regex.match(sprintf("^%s$", [grant.resource.name]), input.resource.name)
+ regex.match(sprintf("^%s$", [grant.resource.type]), input.resource.type)
+}
+
+# user_is_admin is true if...
+user_is_admin if {
+ # "admin" is among the user's roles as per data.user_roles
+ "admin" in data.user_roles[input.authenticationResult.identity]
+}
+
+# user_is_granted is a set of grants for the user identified in the request.
+# The `grant` will be contained if the set `user_is_granted` for every...
+user_is_granted contains grant if {
+ # `role` assigned an element of the user_roles for this user...
+ some role in data.user_roles[input.authenticationResult.identity]
+
+ # `grant` assigned a single grant from the grants list for 'role'...
+ some grant in data.role_grants[role]
+}
diff --git a/extensions-contrib/druid-opa-authorizer/example/ldap/bootstrap.ldif b/extensions-contrib/druid-opa-authorizer/example/ldap/bootstrap.ldif
new file mode 100644
index 000000000000..7c5c9af1bb32
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/example/ldap/bootstrap.ldif
@@ -0,0 +1,91 @@
+# 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.
+
+dn: ou=Users,dc=example,dc=org
+objectClass: top
+objectClass: organizationalUnit
+ou: Users
+
+dn: uid=alice,ou=Users,dc=example,dc=org
+uid: alice
+cn: alice
+sn: alice
+objectClass: top
+objectClass: posixAccount
+objectClass: inetOrgPerson
+homeDirectory: /home/alice
+uidNumber: 1
+gidNumber: 1
+userPassword: alice
+
+dn: uid=bob,ou=Users,dc=example,dc=org
+uid: bob
+cn: bob
+sn: bob
+objectClass: top
+objectClass: posixAccount
+objectClass: inetOrgPerson
+homeDirectory: /home/bob
+uidNumber: 2
+gidNumber: 2
+userPassword: bob
+
+dn: uid=christy,ou=Users,dc=example,dc=org
+uid: christy
+cn: christy
+sn: christy
+objectClass: top
+objectClass: posixAccount
+objectClass: inetOrgPerson
+homeDirectory: /home/christy
+uidNumber: 3
+gidNumber: 3
+userPassword: christy
+
+dn: uid=dylan,ou=Users,dc=example,dc=org
+uid: dylan
+cn: dylan
+sn: dylan
+objectClass: top
+objectClass: posixAccount
+objectClass: inetOrgPerson
+homeDirectory: /home/dylan
+uidNumber: 4
+gidNumber: 4
+userPassword: dylan
+
+dn: uid=eve,ou=Users,dc=example,dc=org
+uid: eve
+cn: eve
+sn: eve
+objectClass: top
+objectClass: posixAccount
+objectClass: inetOrgPerson
+homeDirectory: /home/eve
+uidNumber: 5
+gidNumber: 5
+userPassword: eve
+
+dn: uid=druid_system,ou=Users,dc=example,dc=org
+uid: druid_system
+cn: druid_system
+sn: druid_system
+objectClass: top
+objectClass: posixAccount
+objectClass: inetOrgPerson
+homeDirectory: /home/druid_system
+uidNumber: 6
+gidNumber: 6
+userPassword: password2
diff --git a/extensions-contrib/druid-opa-authorizer/example/ldap/run-ldap.sh b/extensions-contrib/druid-opa-authorizer/example/ldap/run-ldap.sh
new file mode 100755
index 000000000000..34effe8a6aae
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/example/ldap/run-ldap.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+# 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.
+
+# Get the directory of this script
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
+
+# Check if the bootstrap.ldif file exists on the host
+if [ ! -f "$DIR/bootstrap.ldif" ]; then
+ echo "Error: $DIR/bootstrap.ldif not found."
+ exit 1
+fi
+
+# Stop and remove any existing ldap-mock container
+docker rm -f ldap-mock 2>/dev/null || true
+
+# Start the ldap-mock container with correct mount
+docker run -d \
+ --name ldap-mock \
+ -p 8389:389 \
+ -v "$DIR/bootstrap.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/bootstrap.ldif" \
+ -e LDAP_DOMAIN="example.org" \
+ -e LDAP_ORGANISATION="Example" \
+ -e LDAP_ADMIN_PASSWORD="admin" \
+ osixia/openldap:1.5.0 --copy-service
diff --git a/extensions-contrib/druid-opa-authorizer/example/setup/alice_pass.json b/extensions-contrib/druid-opa-authorizer/example/setup/alice_pass.json
new file mode 100644
index 000000000000..5333cfbfde74
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/example/setup/alice_pass.json
@@ -0,0 +1,3 @@
+{
+ "password": "alice"
+}
\ No newline at end of file
diff --git a/extensions-contrib/druid-opa-authorizer/example/setup/bob_pass.json b/extensions-contrib/druid-opa-authorizer/example/setup/bob_pass.json
new file mode 100644
index 000000000000..d9f3122ac2f5
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/example/setup/bob_pass.json
@@ -0,0 +1,3 @@
+{
+ "password": "bob"
+}
\ No newline at end of file
diff --git a/extensions-contrib/druid-opa-authorizer/example/setup/christy_pass.json b/extensions-contrib/druid-opa-authorizer/example/setup/christy_pass.json
new file mode 100644
index 000000000000..4b1489bf3a9f
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/example/setup/christy_pass.json
@@ -0,0 +1,3 @@
+{
+ "password": "christy"
+}
\ No newline at end of file
diff --git a/extensions-contrib/druid-opa-authorizer/example/setup/dylan_pass.json b/extensions-contrib/druid-opa-authorizer/example/setup/dylan_pass.json
new file mode 100644
index 000000000000..97cc676cdea4
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/example/setup/dylan_pass.json
@@ -0,0 +1,3 @@
+{
+ "password": "dylan"
+}
\ No newline at end of file
diff --git a/extensions-contrib/druid-opa-authorizer/example/setup/eve_pass.json b/extensions-contrib/druid-opa-authorizer/example/setup/eve_pass.json
new file mode 100644
index 000000000000..93bce8bcb769
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/example/setup/eve_pass.json
@@ -0,0 +1,3 @@
+{
+ "password": "eve"
+}
\ No newline at end of file
diff --git a/extensions-contrib/druid-opa-authorizer/example/setup/setup.sh b/extensions-contrib/druid-opa-authorizer/example/setup/setup.sh
new file mode 100755
index 000000000000..698ce58768ef
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/example/setup/setup.sh
@@ -0,0 +1,32 @@
+#! /bin/bash
+# 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.
+
+coordinator_ip=localhost
+coordinator_port=8081
+protocol=http
+authenticator_name=basicAuthenticator
+
+create_user() {
+ local user=$1
+ curl -u admin:password1 -XPOST ${protocol}://$coordinator_ip:${coordinator_port}/druid-ext/basic-security/authentication/db/${authenticator_name}/users/${user}
+ curl -u admin:password1 -H'Content-Type: application/json' -XPOST --data-binary @${user}_pass.json ${protocol}://$coordinator_ip:${coordinator_port}/druid-ext/basic-security/authentication/db/${authenticator_name}/users/${user}/credentials
+}
+
+create_user alice
+create_user bob
+create_user christy
+create_user dylan
+create_user eve
diff --git a/extensions-contrib/druid-opa-authorizer/pom.xml b/extensions-contrib/druid-opa-authorizer/pom.xml
new file mode 100644
index 000000000000..2085e7752874
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/pom.xml
@@ -0,0 +1,96 @@
+
+
+
+ 4.0.0
+
+
+ org.apache.druid
+ druid
+ 38.0.0-SNAPSHOT
+ ../../pom.xml
+
+
+ org.apache.druid.extensions.contrib
+ druid-opa-authorizer
+ druid-opa-authorizer
+ Apache Druid extension to request policy decisions from Open Policy Agent (OPA)
+
+
+
+ com.google.guava
+ guava
+ provided
+
+
+ com.google.inject
+ guice
+ provided
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ provided
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+ provided
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ provided
+
+
+ org.apache.druid
+ druid-server
+ ${project.parent.version}
+ provided
+
+
+ org.apache.druid
+ druid-processing
+ ${project.parent.version}
+ provided
+
+
+ junit
+ junit
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+
+
+
+
+ org.owasp
+ dependency-check-maven
+
+ true
+
+
+
+
+
\ No newline at end of file
diff --git a/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/OpaAuthorizer.java b/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/OpaAuthorizer.java
new file mode 100644
index 000000000000..9b2287aae323
--- /dev/null
+++ b/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/OpaAuthorizer.java
@@ -0,0 +1,254 @@
+/*
+ * 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.druid.security.opa;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.druid.java.util.common.logger.Logger;
+import org.apache.druid.security.opa.opatypes.OpaMessage;
+import org.apache.druid.security.opa.opatypes.OpaResponse;
+import org.apache.druid.server.security.Access;
+import org.apache.druid.server.security.Action;
+import org.apache.druid.server.security.AuthenticationResult;
+import org.apache.druid.server.security.Authorizer;
+import org.apache.druid.server.security.Resource;
+
+import javax.naming.NamingEnumeration;
+import javax.naming.NamingException;
+import javax.naming.directory.Attribute;
+import javax.naming.directory.Attributes;
+import javax.naming.directory.SearchResult;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+@JsonTypeName("opa")
+public class OpaAuthorizer implements Authorizer
+{
+ private static final Logger LOG = new Logger(OpaAuthorizer.class);
+ private static final long DEFAULT_TIMEOUT_MS = 5_000;
+ private final URI opaUri;
+ private final Duration timeout;
+ private final ObjectMapper objectMapper;
+ private final HttpClient httpClient;
+
+ @JsonCreator
+ public OpaAuthorizer(
+ @JsonProperty("name") String name,
+ @JsonProperty("opaUri") String opaUri,
+ @JsonProperty("timeoutMs") Long timeoutMs
+ )
+ {
+ this(
+ name,
+ opaUri,
+ timeoutMs,
+ HttpClient.newBuilder()
+ .connectTimeout(Duration.ofMillis(timeoutMs != null ? timeoutMs : DEFAULT_TIMEOUT_MS))
+ .build()
+ );
+ }
+
+ public OpaAuthorizer(
+ String name,
+ String opaUri,
+ Long timeoutMs,
+ HttpClient httpClient
+ )
+ {
+ try {
+ this.opaUri = new URI(opaUri);
+ }
+ catch (Exception e) {
+ throw new IllegalArgumentException("Invalid opaUri: " + opaUri, e);
+ }
+ this.objectMapper =
+ new ObjectMapper()
+ // https://github.com/stackabletech/druid-opa-authorizer/issues/72
+ // OPA server can send other fields, such as `decision_id` when enabling decision logs
+ // We could add all the fields we *currently* know, but it's more future-proof to ignore
+ // any unknown fields.
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
+ this.timeout = Duration.ofMillis(timeoutMs != null ? timeoutMs : DEFAULT_TIMEOUT_MS);
+ this.httpClient = httpClient;
+ // name is required for @JsonCreator but unused in this implementation
+ LOG.debug("Created OpaAuthorizer [%s]", name);
+ }
+
+ @Override
+ public Access authorize(
+ AuthenticationResult authenticationResult,
+ Resource resource,
+ Action action
+ )
+ {
+ LOG.debug(
+ "Authorizing [%s] for [%s] on [%s]",
+ authenticationResult.getIdentity(),
+ action.name(),
+ resource.toString()
+ );
+
+ final AuthenticationResult sanitizedAuthResult = new AuthenticationResult(
+ authenticationResult.getIdentity(),
+ authenticationResult.getAuthorizerName(),
+ authenticationResult.getAuthenticatedBy(),
+ sanitizeContext(authenticationResult.getContext())
+ );
+
+ LOG.trace("Creating OPA request JSON.");
+ final OpaMessage msg = new OpaMessage(
+ sanitizedAuthResult,
+ action.name(),
+ resource.getName(),
+ resource.getType()
+ );
+ final String msgJson;
+ try {
+ msgJson = objectMapper.writeValueAsString(msg);
+ }
+ catch (JsonProcessingException e) {
+ return Access.deny("Failed to create the OPA request JSON: " + e);
+ }
+
+ LOG.trace("Executing post to OPA.");
+ try {
+ final HttpRequest request =
+ HttpRequest.newBuilder()
+ .uri(opaUri)
+ .header("Content-Type", "application/json")
+ .timeout(timeout)
+ .POST(HttpRequest.BodyPublishers.ofString(msgJson))
+ .build();
+
+ final HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+
+ LOG.debug("OPA response code [%s]", response.statusCode());
+ LOG.trace("OPA response body [%s]", response.body());
+
+ if (response.statusCode() != HttpURLConnection.HTTP_OK) {
+ return Access.deny(
+ "OPA request failed with status code [" + response.statusCode() + "]"
+ );
+ }
+
+ LOG.trace("Parsing OPA response.");
+ final OpaResponse opaResponse = objectMapper.readValue(response.body(), OpaResponse.class);
+ if (opaResponse.isResult()) {
+ return Access.OK;
+ } else {
+ return Access.DENIED;
+ }
+
+ }
+ catch (Exception e) {
+ return Access.deny("An error occurred: " + e);
+ }
+ }
+
+ protected Map sanitizeContext(Map context)
+ {
+ if (context == null || context.isEmpty()) {
+ return context;
+ }
+
+ final Map sanitizedContext = new HashMap<>();
+ for (final Map.Entry entry : context.entrySet()) {
+ if (entry.getValue() instanceof SearchResult) {
+ try {
+ sanitizedContext.put(entry.getKey(), sanitizeSearchResult((SearchResult) entry.getValue()));
+ }
+ catch (NamingException e) {
+ LOG.warn(e, "Failed to sanitize SearchResult in context key [%s]", entry.getKey());
+ sanitizedContext.put(entry.getKey(), Collections.emptyMap());
+ }
+ } else {
+ // Keep other types as is, assuming they are serializable or handled by other means
+ sanitizedContext.put(entry.getKey(), entry.getValue());
+ }
+ }
+ return sanitizedContext;
+ }
+
+ private Map sanitizeSearchResult(SearchResult searchResult) throws NamingException
+ {
+ final Map sanitized = new HashMap<>();
+ sanitized.put("name", searchResult.getName());
+
+ try {
+ sanitized.put("nameInNamespace", searchResult.getNameInNamespace());
+ }
+ catch (UnsupportedOperationException e) {
+ // SearchResult.getNameInNamespace() throws UnsupportedOperationException if the result is relative
+ // and the full name hasn't been set by the context. It's safe to ignore.
+ }
+
+ final Map> attributesMap = new HashMap<>();
+ final Attributes attributes = searchResult.getAttributes();
+ if (attributes != null) {
+ final NamingEnumeration extends Attribute> attrEnum = attributes.getAll();
+ try {
+ while (attrEnum != null && attrEnum.hasMore()) {
+ final Attribute attr = attrEnum.next();
+ final List