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 @@ -c org.apache.druid.extensions.contrib:druid-rabbit-indexing-service -c + org.apache.druid.extensions.contrib:druid-opa-authorizer + -c org.apache.druid.extensions.contrib:grpc-query -c org.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.extensions druid-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 attrEnum = attributes.getAll(); + try { + while (attrEnum != null && attrEnum.hasMore()) { + final Attribute attr = attrEnum.next(); + final List values = new ArrayList<>(); + final NamingEnumeration valueEnum = attr.getAll(); + try { + while (valueEnum != null && valueEnum.hasMore()) { + final Object val = valueEnum.next(); + if (val instanceof byte[]) { + values.add(Base64.getEncoder().encodeToString((byte[]) val)); + } else if (val != null) { + values.add(val.toString()); + } + } + } + finally { + if (valueEnum != null) { + valueEnum.close(); + } + } + attributesMap.put(attr.getID().toLowerCase(Locale.ENGLISH), values); + } + } + finally { + if (attrEnum != null) { + attrEnum.close(); + } + } + } + sanitized.put("attributes", attributesMap); + return sanitized; + } +} diff --git a/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/OpaDruidModule.java b/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/OpaDruidModule.java new file mode 100644 index 000000000000..8cc43c8337e7 --- /dev/null +++ b/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/OpaDruidModule.java @@ -0,0 +1,43 @@ +/* + * 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.databind.Module; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.google.common.collect.ImmutableList; +import com.google.inject.Binder; +import org.apache.druid.initialization.DruidModule; + +import java.util.List; + +public class OpaDruidModule implements DruidModule +{ + + @Override + public void configure(Binder binder) + { + } + + @Override + public List getJacksonModules() + { + return ImmutableList.of(new SimpleModule("Opa").registerSubtypes(OpaAuthorizer.class)); + } +} diff --git a/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/opatypes/OpaInput.java b/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/opatypes/OpaInput.java new file mode 100644 index 000000000000..0e2e7f3888e3 --- /dev/null +++ b/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/opatypes/OpaInput.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.security.opa.opatypes; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.druid.server.security.AuthenticationResult; + +public class OpaInput +{ + private final AuthenticationResult authenticationResult; + private final String action; + private final OpaResource resource; + + public OpaInput( + @JsonProperty("authenticationResult") AuthenticationResult authenticationResult, + @JsonProperty("action") String action, + @JsonProperty("resourceName") String resourceName, + @JsonProperty("resourceType") String resourceType + ) + { + this.authenticationResult = authenticationResult; + this.action = action; + this.resource = new OpaResource(resourceName, resourceType); + } + + @JsonProperty + public AuthenticationResult getAuthenticationResult() + { + return authenticationResult; + } + + @JsonProperty + public String getAction() + { + return action; + } + + @JsonProperty + public OpaResource getResource() + { + return resource; + } +} diff --git a/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/opatypes/OpaMessage.java b/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/opatypes/OpaMessage.java new file mode 100644 index 000000000000..3cb10be5e830 --- /dev/null +++ b/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/opatypes/OpaMessage.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.security.opa.opatypes; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.druid.server.security.AuthenticationResult; + +public class OpaMessage +{ + private final OpaInput input; + + public OpaMessage( + @JsonProperty("authenticationResult") AuthenticationResult authenticationResult, + @JsonProperty("action") String action, + @JsonProperty("resourceName") String resourceName, + @JsonProperty("resourceType") String resourceType + ) + { + this.input = new OpaInput(authenticationResult, action, resourceName, resourceType); + } + + @JsonProperty + public OpaInput getInput() + { + return input; + } +} diff --git a/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/opatypes/OpaResource.java b/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/opatypes/OpaResource.java new file mode 100644 index 000000000000..3f14018cd3ea --- /dev/null +++ b/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/opatypes/OpaResource.java @@ -0,0 +1,49 @@ +/* + * 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.opatypes; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class OpaResource +{ + private final String name; + private final String type; + + public OpaResource( + @JsonProperty("name") String name, + @JsonProperty("type") String type + ) + { + this.name = name; + this.type = type; + } + + @JsonProperty + public String getName() + { + return name; + } + + @JsonProperty + public String getType() + { + return type; + } +} diff --git a/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/opatypes/OpaResponse.java b/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/opatypes/OpaResponse.java new file mode 100644 index 000000000000..e8b9fa969c17 --- /dev/null +++ b/extensions-contrib/druid-opa-authorizer/src/main/java/org/apache/druid/security/opa/opatypes/OpaResponse.java @@ -0,0 +1,40 @@ +/* + * 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.opatypes; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class OpaResponse +{ + private final boolean result; + + @JsonCreator + public OpaResponse(@JsonProperty("result") boolean result) + { + this.result = result; + } + + @JsonProperty + public boolean isResult() + { + return result; + } +} diff --git a/extensions-contrib/druid-opa-authorizer/src/main/resources/META-INF/services/org.apache.druid.initialization.DruidModule b/extensions-contrib/druid-opa-authorizer/src/main/resources/META-INF/services/org.apache.druid.initialization.DruidModule new file mode 100644 index 000000000000..6623a9462e32 --- /dev/null +++ b/extensions-contrib/druid-opa-authorizer/src/main/resources/META-INF/services/org.apache.druid.initialization.DruidModule @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +org.apache.druid.security.opa.OpaDruidModule diff --git a/extensions-contrib/druid-opa-authorizer/src/test/java/org/apache/druid/security/opa/OpaAuthorizerTest.java b/extensions-contrib/druid-opa-authorizer/src/test/java/org/apache/druid/security/opa/OpaAuthorizerTest.java new file mode 100644 index 000000000000..4a5d243886c9 --- /dev/null +++ b/extensions-contrib/druid-opa-authorizer/src/test/java/org/apache/druid/security/opa/OpaAuthorizerTest.java @@ -0,0 +1,237 @@ +/* + * 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 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.Resource; +import org.apache.druid.server.security.ResourceType; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; + +import javax.naming.directory.BasicAttributes; +import javax.naming.directory.SearchResult; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Flow; + +public class OpaAuthorizerTest +{ + private HttpClient httpClient; + private OpaAuthorizer opaAuthorizer; + private static final String OPA_URI = "http://localhost:8181/v1/data/druid/allow"; + + // Helper to extract body from HttpRequest for testing + private static String getBody(HttpRequest request) + { + if (request.bodyPublisher().isEmpty()) { + return ""; + } + final StringBuilder sb = new StringBuilder(); + request.bodyPublisher().get().subscribe(new Flow.Subscriber<>() + { + @Override + public void onSubscribe(Flow.Subscription subscription) + { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(ByteBuffer item) + { + final byte[] bytes = new byte[item.remaining()]; + item.get(bytes); + sb.append(new String(bytes, StandardCharsets.UTF_8)); + } + + @Override + public void onError(Throwable throwable) + { + } + + @Override + public void onComplete() + { + } + }); + return sb.toString(); + } + + @Before + public void setUp() + { + httpClient = Mockito.mock(HttpClient.class); + opaAuthorizer = new OpaAuthorizer("opa", OPA_URI, null, httpClient); + } + + @Test + public void testAuthorizeAllowed() throws Exception + { + @SuppressWarnings("unchecked") + HttpResponse response = Mockito.mock(HttpResponse.class); + Mockito.when(response.statusCode()).thenReturn(200); + Mockito.when(response.body()).thenReturn("{\"result\": true}"); + Mockito.when(httpClient.send(ArgumentMatchers.any(HttpRequest.class), ArgumentMatchers.>any())) + .thenReturn(response); + + final AuthenticationResult authResult = new AuthenticationResult("user", "authorizer", "authenticator", null); + final Resource resource = new Resource("dataSource", ResourceType.DATASOURCE); + final Access access = opaAuthorizer.authorize(authResult, resource, Action.READ); + + Assert.assertTrue(access.isAllowed()); + } + + @Test + public void testAuthorizeDenied() throws Exception + { + @SuppressWarnings("unchecked") + HttpResponse response = Mockito.mock(HttpResponse.class); + Mockito.when(response.statusCode()).thenReturn(200); + Mockito.when(response.body()).thenReturn("{\"result\": false}"); + Mockito.when(httpClient.send(ArgumentMatchers.any(HttpRequest.class), ArgumentMatchers.>any())) + .thenReturn(response); + + final AuthenticationResult authResult = new AuthenticationResult("user", "authorizer", "authenticator", null); + final Resource resource = new Resource("dataSource", ResourceType.DATASOURCE); + final Access access = opaAuthorizer.authorize(authResult, resource, Action.READ); + + Assert.assertFalse(access.isAllowed()); + Assert.assertEquals(Access.DENIED.getMessage(), access.getMessage()); + } + + @Test + public void testAuthorizeError() throws Exception + { + Mockito.when(httpClient.send(ArgumentMatchers.any(HttpRequest.class), ArgumentMatchers.>any())) + .thenThrow(new RuntimeException("Network error")); + + final AuthenticationResult authResult = new AuthenticationResult("user", "authorizer", "authenticator", null); + final Resource resource = new Resource("dataSource", ResourceType.DATASOURCE); + final Access access = opaAuthorizer.authorize(authResult, resource, Action.READ); + + Assert.assertFalse(access.isAllowed()); + Assert.assertTrue(access.getMessage().contains("Unauthorized, An error occurred: java.lang.RuntimeException: Network error")); + } + + @Test + public void testAuthorizeNon200Response() throws Exception + { + @SuppressWarnings("unchecked") + HttpResponse response = Mockito.mock(HttpResponse.class); + Mockito.when(response.statusCode()).thenReturn(500); + Mockito.when(response.body()).thenReturn("Internal Server Error"); + Mockito.when(httpClient.send(ArgumentMatchers.any(HttpRequest.class), ArgumentMatchers.>any())) + .thenReturn(response); + + final AuthenticationResult authResult = new AuthenticationResult("user", "authorizer", "authenticator", null); + final Resource resource = new Resource("dataSource", ResourceType.DATASOURCE); + final Access access = opaAuthorizer.authorize(authResult, resource, Action.READ); + + Assert.assertFalse(access.isAllowed()); + Assert.assertTrue(access.getMessage().contains("OPA request failed with status code [500]")); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidUri() + { + final OpaAuthorizer ignored = new OpaAuthorizer("opa", "invalid uri", null, httpClient); + Assert.assertNotNull(ignored); + } + + @Test + public void testAuthorizeWithNonSerializableContext() throws Exception + { + @SuppressWarnings("unchecked") + HttpResponse response = Mockito.mock(HttpResponse.class); + Mockito.when(response.statusCode()).thenReturn(200); + Mockito.when(response.body()).thenReturn("{\"result\": true}"); + + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + Mockito.when(httpClient.send(requestCaptor.capture(), ArgumentMatchers.>any())) + .thenReturn(response); + + // Mimic LDAP SearchResult which has non-serializable elements + final BasicAttributes attributes = new BasicAttributes(); + attributes.put("uid", "user"); + attributes.put("memberOf", "cn=group1,ou=Groups,dc=example,dc=org"); + attributes.get("memberOf").add("cn=group2,ou=Groups,dc=example,dc=org"); + final byte[] photoBytes = new byte[]{1, 2, 3}; + attributes.put("jpegPhoto", photoBytes); + final SearchResult searchResult = new SearchResult("uid=user", "java.lang.Object", null, attributes, false); + searchResult.setNameInNamespace("dc=example,dc=org"); + + final Map context = new HashMap<>(); + context.put("searchResult", searchResult); + + final AuthenticationResult authResult = new AuthenticationResult("user", "authorizer", "authenticator", context); + final Resource resource = new Resource("dataSource", ResourceType.DATASOURCE); + final Access access = opaAuthorizer.authorize(authResult, resource, Action.READ); + + Assert.assertTrue(access.isAllowed()); + + final HttpRequest capturedRequest = requestCaptor.getValue(); + final String requestBody = getBody(capturedRequest); + + Assert.assertTrue(requestBody.contains("\"name\":\"uid=user\"")); + Assert.assertTrue(requestBody.contains("\"nameInNamespace\":\"dc=example,dc=org\"")); + Assert.assertTrue(requestBody.contains("\"uid\":[\"user\"]")); + Assert.assertTrue(requestBody.contains("\"memberof\":[\"cn=group1,ou=Groups,dc=example,dc=org\",\"cn=group2,ou=Groups,dc=example,dc=org\"]")); + Assert.assertTrue(requestBody.contains("\"jpegphoto\":[\"AQID\"]")); // Base64 for [1, 2, 3] + } + + @Test + public void testAuthorizeTimeout() throws Exception + { + Mockito.when(httpClient.send(ArgumentMatchers.any(HttpRequest.class), ArgumentMatchers.>any())) + .thenThrow(new HttpTimeoutException("request timed out")); + + final AuthenticationResult authResult = new AuthenticationResult("user", "authorizer", "authenticator", null); + final Resource resource = new Resource("dataSource", ResourceType.DATASOURCE); + final Access access = opaAuthorizer.authorize(authResult, resource, Action.READ); + + Assert.assertFalse(access.isAllowed()); + Assert.assertTrue(access.getMessage().contains("HttpTimeoutException")); + } + + @Test + public void testCustomTimeout() + { + final OpaAuthorizer customTimeout = new OpaAuthorizer("opa", OPA_URI, 5000L, httpClient); + Assert.assertNotNull(customTimeout); + } + + @Test + public void testDefaultTimeout() + { + final OpaAuthorizer defaultTimeout = new OpaAuthorizer("opa", OPA_URI, null, httpClient); + Assert.assertNotNull(defaultTimeout); + } +} diff --git a/extensions-contrib/druid-opa-authorizer/src/test/java/org/apache/druid/security/opa/opatypes/OpaMessageTest.java b/extensions-contrib/druid-opa-authorizer/src/test/java/org/apache/druid/security/opa/opatypes/OpaMessageTest.java new file mode 100644 index 000000000000..d6bf0c987ff1 --- /dev/null +++ b/extensions-contrib/druid-opa-authorizer/src/test/java/org/apache/druid/security/opa/opatypes/OpaMessageTest.java @@ -0,0 +1,57 @@ +/* + * 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.opatypes; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.druid.server.security.AuthenticationResult; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Map; + +public class OpaMessageTest +{ + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + @SuppressWarnings("unchecked") + public void testSerialization() throws Exception + { + final AuthenticationResult authResult = new AuthenticationResult("user", "authorizer", "authenticator", null); + final OpaMessage message = new OpaMessage(authResult, "READ", "resource", "type"); + + final String json = mapper.writeValueAsString(message); + + // Verify the structure: {"input":{"authenticationResult":{"identity":"user",...},"action":"READ","resource":{"name":"resource","type":"type"}}} + final Map map = mapper.readValue(json, new TypeReference<>() {}); + Assert.assertTrue(map.containsKey("input")); + + final Map input = (Map) map.get("input"); + Assert.assertEquals("READ", input.get("action")); + + final Map resource = (Map) input.get("resource"); + Assert.assertEquals("resource", resource.get("name")); + Assert.assertEquals("type", resource.get("type")); + + final Map authenticationResult = (Map) input.get("authenticationResult"); + Assert.assertEquals("user", authenticationResult.get("identity")); + } +} diff --git a/extensions-contrib/druid-opa-authorizer/src/test/java/org/apache/druid/security/opa/opatypes/OpaResponseTest.java b/extensions-contrib/druid-opa-authorizer/src/test/java/org/apache/druid/security/opa/opatypes/OpaResponseTest.java new file mode 100644 index 000000000000..1fcf5af97d9a --- /dev/null +++ b/extensions-contrib/druid-opa-authorizer/src/test/java/org/apache/druid/security/opa/opatypes/OpaResponseTest.java @@ -0,0 +1,55 @@ +/* + * 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.opatypes; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Test; + +public class OpaResponseTest +{ + private final ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + @Test + public void testDeserializeAllowed() throws Exception + { + final String json = "{\"result\": true}"; + final OpaResponse response = mapper.readValue(json, OpaResponse.class); + Assert.assertTrue(response.isResult()); + } + + @Test + public void testDeserializeDenied() throws Exception + { + final String json = "{\"result\": false}"; + final OpaResponse response = mapper.readValue(json, OpaResponse.class); + Assert.assertFalse(response.isResult()); + } + + @Test + public void testDeserializeWithUnknownFields() throws Exception + { + final String json = "{\"result\": true, \"decision_id\": \"12345\", \"unknown\": { \"foo\": \"bar\" }}"; + final OpaResponse response = mapper.readValue(json, OpaResponse.class); + Assert.assertTrue(response.isResult()); + } +} diff --git a/pom.xml b/pom.xml index b8c72b78a036..87548ebf7e6e 100644 --- a/pom.xml +++ b/pom.xml @@ -249,6 +249,7 @@ extensions-contrib/druid-exact-count-bitmap extensions-contrib/statsd-emitter extensions-contrib/time-min-max + extensions-contrib/druid-opa-authorizer extensions-contrib/virtual-columns extensions-contrib/thrift-extensions extensions-contrib/ambari-metrics-emitter diff --git a/website/.spelling b/website/.spelling index e1723b254dca..6fc8417d0860 100644 --- a/website/.spelling +++ b/website/.spelling @@ -212,6 +212,7 @@ OCF OIDC OLAP OOMs +OPA OpenJDK OpenLDAP openlineage @@ -2403,6 +2404,7 @@ druid-orc-extensions druid-parquet-extensions druid-protobuf-extensions druid-ranger-security +druid-opa-authorizer druid-s3-extensions druid-ec2-extensions druid-aws-rds-extensions @@ -2656,3 +2658,13 @@ nginx - ../docs/development/extensions-core/s3.md NIO + +- ../docs/development/extensions-contrib/druid-opa-authorizer.md +OPA +Rego +alice +christy +dylan +eve +opaUri +rego