You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by th...@apache.org on 2021/11/01 21:48:19 UTC
[solr] branch main updated: SOLR-12666: Add authn & authz plugins
that supports multiple authentication schemes,
such as Bearer and Basic (#355)
This is an automated email from the ASF dual-hosted git repository.
thelabdude pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git
The following commit(s) were added to refs/heads/main by this push:
new f0139e5 SOLR-12666: Add authn & authz plugins that supports multiple authentication schemes, such as Bearer and Basic (#355)
f0139e5 is described below
commit f0139e5ee86f1e8094af0322186eea9af9d9c021
Author: Timothy Potter <th...@gmail.com>
AuthorDate: Mon Nov 1 15:48:13 2021 -0600
SOLR-12666: Add authn & authz plugins that supports multiple authentication schemes, such as Bearer and Basic (#355)
---
solr/CHANGES.txt | 2 +
.../java/org/apache/solr/core/CoreContainer.java | 6 +-
.../org/apache/solr/security/BasicAuthPlugin.java | 2 +-
.../org/apache/solr/security/MultiAuthPlugin.java | 268 +++++++++++++++++++++
.../MultiAuthRuleBasedAuthorizationPlugin.java | 150 ++++++++++++
.../solr/security/multi_auth_plugin_security.json | 65 +++++
.../solr/security/BasicAuthStandaloneTest.java | 32 ++-
.../apache/solr/security/MultiAuthPluginTest.java | 241 ++++++++++++++++++
.../src/basic-authentication-plugin.adoc | 64 +++++
.../src/jwt-authentication-plugin.adoc | 6 +
.../src/rule-based-authorization-plugin.adoc | 37 ++-
.../cluster.security.MultiPluginAuth.Commands.json | 27 +++
12 files changed, 887 insertions(+), 13 deletions(-)
diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index ce9cc49..92b907e 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -413,6 +413,8 @@ New Features
* SOLR-15708: SolrJ support for ConfigSet uploading (ab, hossman)
+* SOLR-12666: Add authn & authz plugins that supports multiple authentication schemes, such as Bearer and Basic (Timothy Potter, janhoy)
+
Improvements
---------------------
diff --git a/solr/core/src/java/org/apache/solr/core/CoreContainer.java b/solr/core/src/java/org/apache/solr/core/CoreContainer.java
index bf80294..1b91ebc 100644
--- a/solr/core/src/java/org/apache/solr/core/CoreContainer.java
+++ b/solr/core/src/java/org/apache/solr/core/CoreContainer.java
@@ -409,7 +409,11 @@ public class CoreContainer {
}
log.info("Initializing authorization plugin: {}", klas);
authorizationPlugin = new SecurityPluginHolder<>(newVersion,
- getResourceLoader().newInstance(klas, AuthorizationPlugin.class));
+ getResourceLoader().newInstance(klas,
+ AuthorizationPlugin.class,
+ null,
+ new Class<?>[]{CoreContainer.class},
+ new Object[]{this}));
// Read and pass the authorization context to the plugin
authorizationPlugin.plugin.init(authorizationConf);
diff --git a/solr/core/src/java/org/apache/solr/security/BasicAuthPlugin.java b/solr/core/src/java/org/apache/solr/security/BasicAuthPlugin.java
index b83f0c6..6415eb5 100644
--- a/solr/core/src/java/org/apache/solr/security/BasicAuthPlugin.java
+++ b/solr/core/src/java/org/apache/solr/security/BasicAuthPlugin.java
@@ -263,7 +263,7 @@ public class BasicAuthPlugin extends AuthenticationPlugin implements ConfigEdita
* @param request the servlet request
* @return true if the request is AJAX request
*/
- private boolean isAjaxRequest(HttpServletRequest request) {
+ static boolean isAjaxRequest(HttpServletRequest request) {
return "XMLHttpRequest".equalsIgnoreCase(request.getHeader(X_REQUESTED_WITH_HEADER));
}
diff --git a/solr/core/src/java/org/apache/solr/security/MultiAuthPlugin.java b/solr/core/src/java/org/apache/solr/security/MultiAuthPlugin.java
new file mode 100644
index 0000000..2857005
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/security/MultiAuthPlugin.java
@@ -0,0 +1,268 @@
+/*
+ * 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.solr.security;
+
+import javax.servlet.FilterChain;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.http.HttpRequest;
+import org.apache.http.protocol.HttpContext;
+import org.apache.lucene.util.ResourceLoader;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.SolrException.ErrorCode;
+import org.apache.solr.common.SpecProvider;
+import org.apache.solr.common.StringUtils;
+import org.apache.solr.common.util.CommandOperation;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.common.util.ValidatingJsonMap;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.metrics.SolrMetricsContext;
+import org.eclipse.jetty.client.api.Request;
+
+/**
+ * Authentication plugin that supports multiple Authorization schemes, such as Bearer and Basic.
+ * The impl simply delegates to one of Solr's other AuthenticationPlugins, such as the BasicAuthPlugin or JWTAuthPlugin.
+ *
+ * @lucene.experimental
+ */
+public class MultiAuthPlugin extends AuthenticationPlugin implements ConfigEditablePlugin, SpecProvider {
+ public static final String PROPERTY_SCHEMES = "schemes";
+ public static final String PROPERTY_SCHEME = "scheme";
+ public static final String AUTHORIZATION_HEADER = "Authorization";
+
+ private static final ThreadLocal<AuthenticationPlugin> pluginInRequest = new ThreadLocal<>();
+ private static final String UNKNOWN_SCHEME = "";
+
+ private final Map<String, AuthenticationPlugin> pluginMap = new LinkedHashMap<>();
+ private final ResourceLoader loader;
+
+ // Get the loader from the CoreContainer so we can load the sub-plugins, such as the BasicAuthPlugin for Basic
+ public MultiAuthPlugin(CoreContainer cc) {
+ this.loader = cc.getResourceLoader();
+ }
+
+ @SuppressWarnings({"unchecked"})
+ static boolean applyEditCommandToSchemePlugin(String scheme, ConfigEditablePlugin plugin, CommandOperation c, Map<String, Object> latestConf) {
+ boolean madeChanges = false;
+ // Send in the config for the plugin only
+ Map<String, Object> latestPluginConf = null;
+ int updateAt = -1;
+ List<Map<String, Object>> schemes = (List<Map<String, Object>>) latestConf.get(PROPERTY_SCHEMES);
+ for (int i = 0; i < schemes.size(); i++) {
+ Map<String, Object> schemeConfig = schemes.get(i);
+ if (scheme.equals(schemeConfig.get(PROPERTY_SCHEME))) {
+ latestPluginConf = withoutScheme(schemeConfig);
+ updateAt = i; // for updating
+ break;
+ }
+ }
+
+ // shouldn't happen
+ if (latestPluginConf == null) {
+ throw new SolrException(ErrorCode.BAD_REQUEST, "Config for scheme '" + scheme + "' not found!");
+ }
+
+ Map<String, Object> updated = plugin.edit(latestPluginConf, Collections.singletonList(c));
+ if (updated != null) {
+ madeChanges = true;
+ schemes.set(updateAt, withScheme(scheme, updated));
+ }
+
+ return madeChanges;
+ }
+
+ private static Map<String, Object> withoutScheme(final Map<String, Object> data) {
+ Map<String, Object> updatedData = new HashMap<>(data);
+ updatedData.remove(PROPERTY_SCHEME);
+ return updatedData;
+ }
+
+ private static Map<String, Object> withScheme(final String scheme, final Map<String, Object> data) {
+ Map<String, Object> updatedData = new HashMap<>(data);
+ updatedData.put(PROPERTY_SCHEME, scheme);
+ return updatedData;
+ }
+
+ @Override
+ @SuppressWarnings({"unchecked"})
+ public void init(Map<String, Object> pluginConfig) {
+ Object o = pluginConfig.get(PROPERTY_SCHEMES);
+ if (!(o instanceof List)) {
+ throw new SolrException(ErrorCode.SERVER_ERROR, "Invalid config: MultiAuthPlugin requires a list of schemes!");
+ }
+
+ List<Object> schemeList = (List<Object>) o;
+ // if you only have one scheme, then you don't need to use this class
+ if (schemeList.size() < 2) {
+ throw new SolrException(ErrorCode.SERVER_ERROR, "Invalid config: MultiAuthPlugin requires at least two schemes!");
+ }
+
+ for (Object s : schemeList) {
+ if (!(s instanceof Map)) {
+ throw new SolrException(ErrorCode.SERVER_ERROR, "Invalid scheme config, expected JSON object but found: " + s);
+ }
+ initPluginForScheme((Map<String, Object>) s);
+ }
+ }
+
+ protected void initPluginForScheme(Map<String, Object> schemeMap) {
+ Map<String, Object> schemeConfig = new HashMap<>(schemeMap);
+
+ String scheme = (String) schemeConfig.remove(PROPERTY_SCHEME);
+ if (StringUtils.isEmpty(scheme)) {
+ throw new SolrException(ErrorCode.SERVER_ERROR, "'scheme' is a required attribute: " + schemeMap);
+ }
+
+ String clazz = (String) schemeConfig.remove("class");
+ if (StringUtils.isEmpty(clazz)) {
+ throw new SolrException(ErrorCode.SERVER_ERROR, "'class' is a required attribute: " + schemeMap);
+ }
+
+ AuthenticationPlugin pluginForScheme = loader.newInstance(clazz, AuthenticationPlugin.class);
+ pluginForScheme.init(schemeConfig);
+ pluginMap.put(scheme.toLowerCase(Locale.ROOT), pluginForScheme);
+ }
+
+ @Override
+ public void initializeMetrics(SolrMetricsContext parentContext, String scope) {
+ for (AuthenticationPlugin plugin : pluginMap.values()) {
+ plugin.initializeMetrics(parentContext, scope);
+ }
+ }
+
+ private String getSchemeFromAuthHeader(final String authHeader) {
+ final int firstSpace = authHeader.indexOf(' ');
+ return (firstSpace != -1) ? authHeader.substring(0, firstSpace).toLowerCase(Locale.ROOT) : UNKNOWN_SCHEME;
+ }
+
+ @Override
+ public boolean doAuthenticate(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws Exception {
+ final String authHeader = request.getHeader(AUTHORIZATION_HEADER);
+
+ // if no Authorization header but is an AJAX request, forward to the default scheme so it can handle it
+ if (authHeader == null) {
+ if (BasicAuthPlugin.isAjaxRequest(request)) {
+ // use the first scheme listed as the default
+ return pluginMap.values().iterator().next().doAuthenticate(request, response, filterChain);
+ }
+
+ throw new SolrException(ErrorCode.UNAUTHORIZED, "No Authorization header");
+ }
+
+ final String scheme = getSchemeFromAuthHeader(authHeader);
+ final AuthenticationPlugin plugin = pluginMap.get(scheme);
+ if (plugin == null) {
+ throw new SolrException(ErrorCode.SERVER_ERROR, "Authorization scheme '" + scheme + "' not supported!");
+ }
+
+ pluginInRequest.set(plugin);
+ return plugin.doAuthenticate(request, response, filterChain);
+ }
+
+ @Override
+ public void close() throws IOException {
+ IOException exc = null;
+ for (AuthenticationPlugin plugin : pluginMap.values()) {
+ try {
+ plugin.close();
+ } catch (IOException ioExc) {
+ if (exc == null) {
+ exc = ioExc;
+ }
+ }
+ }
+
+ if (exc != null) {
+ throw exc;
+ }
+ }
+
+ @Override
+ public void closeRequest() {
+ AuthenticationPlugin plugin = pluginInRequest.get();
+ if (plugin != null) {
+ plugin.closeRequest();
+ pluginInRequest.remove();
+ }
+ }
+
+ @Override
+ protected boolean interceptInternodeRequest(HttpRequest httpRequest, HttpContext httpContext) {
+ for (AuthenticationPlugin plugin : pluginMap.values()) {
+ if (plugin.interceptInternodeRequest(httpRequest, httpContext)) {
+ return true; // first one to fire wins
+ }
+ }
+ return false;
+ }
+
+ @Override
+ protected boolean interceptInternodeRequest(Request request) {
+ for (AuthenticationPlugin plugin : pluginMap.values()) {
+ if (plugin.interceptInternodeRequest(request)) {
+ return true; // first one to fire wins
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public ValidatingJsonMap getSpec() {
+ return Utils.getSpec("cluster.security.MultiPluginAuth.Commands").getSpec();
+ }
+
+ @Override
+ public Map<String, Object> edit(Map<String, Object> latestConf, List<CommandOperation> commands) {
+ boolean madeChanges = false;
+
+ for (CommandOperation c : commands) {
+ Map<String, Object> dataMap = c.getDataMap();
+ // expect the "scheme" wrapper map around the actual command data
+ if (dataMap == null || dataMap.size() != 1) {
+ throw new SolrException(ErrorCode.BAD_REQUEST, "All edit commands must include a 'scheme' wrapper object!");
+ }
+
+ final String scheme = dataMap.keySet().iterator().next().toLowerCase(Locale.ROOT);
+ AuthenticationPlugin plugin = pluginMap.get(scheme);
+ if (plugin == null) {
+ throw new SolrException(ErrorCode.BAD_REQUEST, "No authentication plugin configured for the '" + scheme + "' scheme!");
+ }
+ if (!(plugin instanceof ConfigEditablePlugin)) {
+ throw new SolrException(ErrorCode.BAD_REQUEST, "Plugin for scheme '" + scheme + "' is not editable!");
+ }
+
+ CommandOperation cmdForPlugin = new CommandOperation(c.name, dataMap.get(scheme));
+ if (applyEditCommandToSchemePlugin(scheme, (ConfigEditablePlugin) plugin, cmdForPlugin, latestConf)) {
+ madeChanges = true;
+ }
+ // copy over any errors from the cloned command
+ for (String err : cmdForPlugin.getErrors()) {
+ c.addError(err);
+ }
+ }
+
+ return madeChanges ? latestConf : null;
+ }
+}
diff --git a/solr/core/src/java/org/apache/solr/security/MultiAuthRuleBasedAuthorizationPlugin.java b/solr/core/src/java/org/apache/solr/security/MultiAuthRuleBasedAuthorizationPlugin.java
new file mode 100644
index 0000000..3aa67b4
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/security/MultiAuthRuleBasedAuthorizationPlugin.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.security;
+
+import java.security.Principal;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.lucene.util.ResourceLoader;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.StringUtils;
+import org.apache.solr.common.util.CommandOperation;
+import org.apache.solr.core.CoreContainer;
+
+import static org.apache.solr.security.MultiAuthPlugin.applyEditCommandToSchemePlugin;
+
+/**
+ * Authorization plugin designed to work with the MultiAuthPlugin to support different AuthorizationPlugin per scheme.
+ *
+ * @lucene.experimental
+ */
+public class MultiAuthRuleBasedAuthorizationPlugin extends RuleBasedAuthorizationPluginBase {
+ private final Map<String, RuleBasedAuthorizationPluginBase> pluginMap = new LinkedHashMap<>();
+ private final ResourceLoader loader;
+
+ // Need the CC to get the resource loader for loading the sub-plugins
+ public MultiAuthRuleBasedAuthorizationPlugin(CoreContainer cc) {
+ this.loader = cc.getResourceLoader();
+ }
+
+ @Override
+ @SuppressWarnings({"unchecked"})
+ public void init(Map<String, Object> initInfo) {
+ super.init(initInfo);
+
+ Object o = initInfo.get(MultiAuthPlugin.PROPERTY_SCHEMES);
+ if (!(o instanceof List)) {
+ throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Invalid config: " + getClass().getName() + " requires a list of schemes!");
+ }
+
+ List<Object> schemeList = (List<Object>) o;
+ if (schemeList.size() < 2) {
+ throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Invalid config: " + getClass().getName() + " requires at least two schemes!");
+ }
+
+ for (Object s : schemeList) {
+ if (!(s instanceof Map)) {
+ throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Invalid scheme config, expected JSON object but found: " + s);
+ }
+ initPluginForScheme((Map<String, Object>) s);
+ }
+ }
+
+ protected void initPluginForScheme(Map<String, Object> schemeMap) {
+ Map<String, Object> schemeConfig = new HashMap<>(schemeMap);
+
+ String scheme = (String) schemeConfig.remove(MultiAuthPlugin.PROPERTY_SCHEME);
+ if (StringUtils.isEmpty(scheme)) {
+ throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "'scheme' is a required attribute: " + schemeMap);
+ }
+
+ String clazz = (String) schemeConfig.remove("class");
+ if (StringUtils.isEmpty(clazz)) {
+ throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "'class' is a required attribute: " + schemeMap);
+ }
+
+ RuleBasedAuthorizationPluginBase pluginForScheme = loader.newInstance(clazz, RuleBasedAuthorizationPluginBase.class);
+ pluginForScheme.init(schemeConfig);
+ pluginMap.put(scheme.toLowerCase(Locale.ROOT), pluginForScheme);
+ }
+
+ /**
+ * Pulls roles from the Principal
+ *
+ * @param principal the user Principal which should contain roles
+ * @return set of roles as strings
+ */
+ @Override
+ public Set<String> getUserRoles(Principal principal) {
+ Set<String> mergedRoles = new HashSet<>();
+ for (RuleBasedAuthorizationPluginBase plugin : pluginMap.values()) {
+ final Set<String> userRoles = plugin.getUserRoles(principal);
+ if (userRoles != null) {
+ mergedRoles.addAll(userRoles);
+ }
+ }
+ return mergedRoles;
+ }
+
+ @Override
+ public Map<String, Object> edit(Map<String, Object> latestConf, List<CommandOperation> commands) {
+ boolean madeChanges = false;
+ for (CommandOperation c : commands) {
+
+ // just let the base class handle permission commands
+ if (c.name.endsWith("-permission")) {
+ Map<String, Object> updated = super.edit(latestConf, Collections.singletonList(c));
+ if (updated != null) {
+ madeChanges = true;
+ latestConf = updated;
+ }
+ continue;
+ }
+
+ Map<String, Object> dataMap = c.getDataMap();
+ // expect the "scheme" wrapper map around the actual command data
+ if (dataMap == null || dataMap.size() != 1) {
+ throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "All edit commands must include a 'scheme' wrapper object!");
+ }
+
+ final String scheme = dataMap.keySet().iterator().next().toLowerCase(Locale.ROOT);
+ RuleBasedAuthorizationPluginBase plugin = pluginMap.get(scheme);
+ if (plugin == null) {
+ throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No authorization plugin configured for the '" + scheme +
+ "' scheme! Did you forget to wrap the command with a scheme object?");
+ }
+
+ CommandOperation cmdForPlugin = new CommandOperation(c.name, dataMap.get(scheme));
+ if (applyEditCommandToSchemePlugin(scheme, plugin, cmdForPlugin, latestConf)) {
+ madeChanges = true;
+ }
+ // copy over any errors from the cloned command
+ for (String err : cmdForPlugin.getErrors()) {
+ c.addError(err);
+ }
+ }
+
+ return madeChanges ? latestConf : null;
+ }
+}
diff --git a/solr/core/src/test-files/solr/security/multi_auth_plugin_security.json b/solr/core/src/test-files/solr/security/multi_auth_plugin_security.json
new file mode 100644
index 0000000..0fd98fb
--- /dev/null
+++ b/solr/core/src/test-files/solr/security/multi_auth_plugin_security.json
@@ -0,0 +1,65 @@
+{
+ "authentication": {
+ "class": "solr.MultiAuthPlugin",
+ "schemes": [{
+ "scheme": "basic",
+ "blockUnknown": false,
+ "class": "solr.BasicAuthPlugin",
+ "credentials": {
+ "admin": "orwp2Ghgj39lmnrZOTm7Qtre1VqHFDfwAEzr0ApbN3Y= Ju5osoAqOX8iafhWpPP01E5P+sg8tK8tHON7rCYZRRw="
+ },
+ "forwardCredentials": false
+ },{
+ "scheme": "mock",
+ "class": "org.apache.solr.security.MultiAuthPluginTest$MockAuthPluginForTesting",
+ "blockUnknown": true
+ }]
+ },
+ "authorization": {
+ "class": "solr.MultiAuthRuleBasedAuthorizationPlugin",
+ "schemes": [
+ {
+ "scheme": "basic",
+ "class": "solr.RuleBasedAuthorizationPlugin",
+ "user-role": {
+ "admin": ["admin"],
+ "mock": ["admin"]
+ }
+ },
+ {
+ "scheme": "mock",
+ "class": "solr.ExternalRoleRuleBasedAuthorizationPlugin"
+ }
+ ],
+ "permissions": [
+ {
+ "name": "read",
+ "role": [
+ "admin",
+ "users"
+ ]
+ },
+ {
+ "name": "update",
+ "role": [
+ "admin"
+ ]
+ },
+ {
+ "name": "security-read",
+ "role": "admin"
+ },
+ {
+ "name": "security-edit",
+ "role": "admin"
+ },
+ {
+ "name": "all",
+ "role": [
+ "admin",
+ "users"
+ ]
+ }
+ ]
+ }
+}
diff --git a/solr/core/src/test/org/apache/solr/security/BasicAuthStandaloneTest.java b/solr/core/src/test/org/apache/solr/security/BasicAuthStandaloneTest.java
index 4f198c5..d70725b 100644
--- a/solr/core/src/test/org/apache/solr/security/BasicAuthStandaloneTest.java
+++ b/solr/core/src/test/org/apache/solr/security/BasicAuthStandaloneTest.java
@@ -26,6 +26,7 @@ import java.util.Base64;
import java.util.Collections;
import java.util.Properties;
+import org.apache.http.Header;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpPost;
@@ -54,8 +55,8 @@ import static org.apache.solr.security.BasicAuthIntegrationTest.verifySecuritySt
public class BasicAuthStandaloneTest extends SolrTestCaseJ4 {
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
- private Path ROOT_DIR = Paths.get(TEST_HOME());
- private Path CONF_DIR = ROOT_DIR.resolve("configsets").resolve("configset-2").resolve("conf");
+ private static final Path ROOT_DIR = Paths.get(TEST_HOME());
+ private static final Path CONF_DIR = ROOT_DIR.resolve("configsets").resolve("configset-2").resolve("conf");
SecurityConfHandlerLocalForTesting securityConfHandler;
SolrInstance instance = null;
@@ -145,13 +146,17 @@ public class BasicAuthStandaloneTest extends SolrTestCaseJ4 {
}
}
- private void doHttpPost(HttpClient cl, String url, String jsonCommand, String basicUser, String basicPass) throws IOException {
+ static void doHttpPost(HttpClient cl, String url, String jsonCommand, String basicUser, String basicPass) throws IOException {
doHttpPost(cl, url, jsonCommand, basicUser, basicPass, 200);
}
- private void doHttpPost(HttpClient cl, String url, String jsonCommand, String basicUser, String basicPass, int expectStatusCode) throws IOException {
+ static void doHttpPost(HttpClient cl, String url, String jsonCommand, String basicUser, String basicPass, int expectStatusCode) throws IOException {
+ doHttpPostWithHeader(cl, url, jsonCommand, getBasicAuthHeader(basicUser, basicPass), expectStatusCode);
+ }
+
+ static void doHttpPostWithHeader(HttpClient cl, String url, String jsonCommand, Header header, int expectStatusCode) throws IOException {
HttpPost httpPost = new HttpPost(url);
- setBasicAuthHeader(httpPost, basicUser, basicPass);
+ httpPost.setHeader(header);
httpPost.setEntity(new ByteArrayEntity(jsonCommand.replaceAll("'", "\"").getBytes(UTF_8)));
httpPost.addHeader("Content-Type", "application/json; charset=UTF-8");
HttpResponse r = cl.execute(httpPost);
@@ -160,14 +165,21 @@ public class BasicAuthStandaloneTest extends SolrTestCaseJ4 {
assertEquals("proper_cred sent, but access denied", expectStatusCode, statusCode);
}
- public static void setBasicAuthHeader(AbstractHttpMessage httpMsg, String user, String pwd) {
+ private static Header getBasicAuthHeader(String user, String pwd) {
String userPass = user + ":" + pwd;
String encoded = Base64.getEncoder().encodeToString(userPass.getBytes(UTF_8));
- httpMsg.setHeader(new BasicHeader("Authorization", "Basic " + encoded));
- log.info("Added Basic Auth security Header {}",encoded );
+ return new BasicHeader("Authorization", "Basic " + encoded);
+ }
+
+ public static void setBasicAuthHeader(AbstractHttpMessage httpMsg, String user, String pwd) {
+ final Header basicAuthHeader = getBasicAuthHeader(user, pwd);
+ httpMsg.setHeader(basicAuthHeader);
+ if (log.isInfoEnabled()) {
+ log.info("Added Basic Auth security Header {}", basicAuthHeader.getValue());
+ }
}
- private JettySolrRunner createAndStartJetty(SolrInstance instance) throws Exception {
+ static JettySolrRunner createAndStartJetty(SolrInstance instance) throws Exception {
Properties nodeProperties = new Properties();
nodeProperties.setProperty("solr.data.dir", instance.getDataDir().toString());
JettySolrRunner jetty = new JettySolrRunner(instance.getHomeDir().toString(), nodeProperties, buildJettyConfig("/solr"));
@@ -176,7 +188,7 @@ public class BasicAuthStandaloneTest extends SolrTestCaseJ4 {
}
- private class SolrInstance {
+ static class SolrInstance {
String name;
Integer port;
Path homeDir;
diff --git a/solr/core/src/test/org/apache/solr/security/MultiAuthPluginTest.java b/solr/core/src/test/org/apache/solr/security/MultiAuthPluginTest.java
new file mode 100644
index 0000000..177a0b4
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/security/MultiAuthPluginTest.java
@@ -0,0 +1,241 @@
+/*
+ * 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.solr.security;
+
+import javax.servlet.FilterChain;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Predicate;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.http.client.HttpClient;
+import org.apache.http.message.BasicHeader;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.client.solrj.embedded.JettySolrRunner;
+import org.apache.solr.client.solrj.impl.HttpClientUtil;
+import org.apache.solr.client.solrj.impl.HttpSolrClient;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.CommandOperation;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.handler.admin.SecurityConfHandler;
+import org.apache.solr.handler.admin.SecurityConfHandlerLocalForTesting;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.apache.solr.cloud.SolrCloudAuthTestCase.NOT_NULL_PREDICATE;
+import static org.apache.solr.security.BasicAuthIntegrationTest.verifySecurityStatus;
+import static org.apache.solr.security.BasicAuthStandaloneTest.SolrInstance;
+import static org.apache.solr.security.BasicAuthStandaloneTest.createAndStartJetty;
+import static org.apache.solr.security.BasicAuthStandaloneTest.doHttpPost;
+import static org.apache.solr.security.BasicAuthStandaloneTest.doHttpPostWithHeader;
+
+public class MultiAuthPluginTest extends SolrTestCaseJ4 {
+
+ private static final String authcPrefix = "/admin/authentication";
+ private static final String authzPrefix = "/admin/authorization";
+
+ final Predicate<Object> NULL_PREDICATE = Objects::isNull;
+ SecurityConfHandlerLocalForTesting securityConfHandler;
+ JettySolrRunner jetty;
+
+ @Before
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ SolrInstance instance = new SolrInstance("inst", null);
+ instance.setUp();
+ jetty = createAndStartJetty(instance);
+ securityConfHandler = new SecurityConfHandlerLocalForTesting(jetty.getCoreContainer());
+ HttpClientUtil.clearRequestInterceptors(); // Clear out any old Authorization headers
+ }
+
+ @Override
+ @After
+ public void tearDown() throws Exception {
+ if (jetty != null) {
+ jetty.stop();
+ jetty = null;
+ }
+ super.tearDown();
+ }
+
+ @Test
+ public void testMultiAuthEditAPI() throws Exception {
+ final String user = "admin";
+ final String pass = "SolrRocks";
+
+ HttpClient cl = null;
+ HttpSolrClient httpSolrClient = null;
+ try {
+ cl = HttpClientUtil.createClient(null);
+ String baseUrl = buildUrl(jetty.getLocalPort(), "/solr");
+ httpSolrClient = getHttpSolrClient(baseUrl);
+
+ verifySecurityStatus(cl, baseUrl + authcPrefix, "/errorMessages", null, 5);
+
+ // Initialize security.json with multiple auth plugins configured
+ String multiAuthPluginSecurityJson =
+ FileUtils.readFileToString(TEST_PATH().resolve("security").resolve("multi_auth_plugin_security.json").toFile(), StandardCharsets.UTF_8);
+ securityConfHandler.persistConf(new SecurityConfHandler.SecurityConfig().setData(Utils.fromJSONString(multiAuthPluginSecurityJson)));
+ securityConfHandler.securityConfEdited();
+ verifySecurityStatus(cl, baseUrl + authcPrefix, "authentication/class", "solr.MultiAuthPlugin", 5, user, pass);
+ verifySecurityStatus(cl, baseUrl + authzPrefix, "authorization/class", "solr.MultiAuthRuleBasedAuthorizationPlugin", 5, user, pass);
+
+ // For the multi-auth plugin, every command is wrapped with an object that identifies the "scheme"
+ String command = "{\n" +
+ "'set-user': {'harry':'HarryIsCool'}\n" +
+ "}";
+ // no scheme identified!
+ doHttpPost(cl, baseUrl + authcPrefix, command, user, pass, 400);
+
+ command = "{\n" +
+ "'set-user': { 'foo': {'harry':'HarryIsCool'} }\n" +
+ "}";
+ // no "foo" scheme configured
+ doHttpPost(cl, baseUrl + authcPrefix, command, user, pass, 400);
+
+ command = "{\n" +
+ "'set-user': { 'basic': {'harry':'HarryIsCool'} }\n" +
+ "}";
+
+ // no creds, should fail ...
+ doHttpPost(cl, baseUrl + authcPrefix, command, null, null, 401);
+ // with basic creds, should pass ...
+ doHttpPost(cl, baseUrl + authcPrefix, command, user, pass, 200);
+ verifySecurityStatus(cl, baseUrl + authcPrefix, "authentication/schemes[0]/credentials/harry", NOT_NULL_PREDICATE, 5, user, pass);
+
+ // authz command but missing the "scheme" wrapper
+ command = "{\n" +
+ "'set-user-role': {'harry':['users']}\n" +
+ "}";
+ doHttpPost(cl, baseUrl + authzPrefix, command, user, pass, 400);
+
+ // add "harry" to the "users" role ...
+ command = "{\n" +
+ "'set-user-role': { 'basic': {'harry':['users']} }\n" +
+ "}";
+ doHttpPost(cl, baseUrl + authzPrefix, command, user, pass, 200);
+ verifySecurityStatus(cl, baseUrl + authzPrefix, "authorization/schemes[0]/user-role/harry", NOT_NULL_PREDICATE, 5, user, pass);
+
+ // give the users role a custom permission
+ verifySecurityStatus(cl, baseUrl + authzPrefix, "authorization/permissions[5]", NULL_PREDICATE, 5, user, pass);
+ command = "{\n" +
+ "'set-permission': { 'name':'k8s-zk', 'role':'users', 'collection':null, 'path':'/admin/zookeeper/status' }\n" +
+ "}";
+ doHttpPost(cl, baseUrl + authzPrefix, command, user, pass, 200);
+ verifySecurityStatus(cl, baseUrl + authzPrefix, "authorization/permissions[5]/path", new ExpectedValuePredicate("/admin/zookeeper/status"), 5, user, pass);
+
+ command = "{\n" +
+ "'update-permission': { 'index':'6', 'name':'k8s-zk', 'role':'users', 'collection':null, 'path':'/admin/zookeeper/status2' }\n" +
+ "}";
+ doHttpPost(cl, baseUrl + authzPrefix, command, user, pass, 200);
+ verifySecurityStatus(cl, baseUrl + authzPrefix, "authorization/permissions[5]/path", new ExpectedValuePredicate("/admin/zookeeper/status2"), 5, user, pass);
+
+ // delete the permission
+ command = "{\n" +
+ "'delete-permission': 6\n" +
+ "}";
+ doHttpPost(cl, baseUrl + authzPrefix, command, user, pass, 200);
+ verifySecurityStatus(cl, baseUrl + authzPrefix, "authorization/permissions[5]", NULL_PREDICATE, 5, user, pass);
+
+ // delete the user
+ command = "{\n" +
+ "'delete-user': { 'basic': 'harry' }\n" +
+ "}";
+
+ doHttpPost(cl, baseUrl + authcPrefix, command, user, pass, 200);
+ verifySecurityStatus(cl, baseUrl + authcPrefix, "authentication/schemes[0]/credentials/harry", NULL_PREDICATE, 5, user, pass);
+
+ // update the property on the mock (just to test routing to the mock plugin)
+ command = "{\n" +
+ "'set-property': { 'mock': { 'blockUnknown':false } }\n" +
+ "}";
+
+ doHttpPostWithHeader(cl, baseUrl + authcPrefix, command, new BasicHeader("Authorization", "mock foo"), 200);
+ verifySecurityStatus(cl, baseUrl + authcPrefix, "authentication/schemes[1]/blockUnknown", new ExpectedValuePredicate(Boolean.FALSE), 5, user, pass);
+ } finally {
+ if (cl != null) {
+ HttpClientUtil.close(cl);
+ }
+ if (httpSolrClient != null) {
+ httpSolrClient.close();
+ }
+ }
+ }
+
+ private static final class MockPrincipal implements Principal, Serializable {
+ @Override
+ public String getName() {
+ return "mock";
+ }
+ }
+
+ public static final class MockAuthPluginForTesting extends AuthenticationPlugin implements ConfigEditablePlugin {
+
+ @Override
+ public void init(Map<String, Object> pluginConfig) {
+
+ }
+
+ @Override
+ public boolean doAuthenticate(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws Exception {
+ Principal principal = new MockPrincipal();
+ request = wrapWithPrincipal(request, principal, "mock");
+ filterChain.doFilter(request, response);
+ return true;
+ }
+
+ @Override
+ public Map<String, Object> edit(Map<String, Object> latestConf, List<CommandOperation> commands) {
+ for (CommandOperation op : commands) {
+ if ("set-property".equals(op.name)) {
+ for (Map.Entry<String, Object> e : op.getDataMap().entrySet()) {
+ if ("blockUnknown".equals(e.getKey())) {
+ latestConf.put(e.getKey(), e.getValue());
+ return latestConf;
+ } else {
+ op.addError("Unknown property " + e.getKey());
+ }
+ }
+ } else {
+ throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Unsupported command: " + op.name);
+ }
+ }
+ return null;
+ }
+ }
+
+ private static final class ExpectedValuePredicate implements Predicate<Object> {
+ final Object expected;
+
+ ExpectedValuePredicate(Object exp) {
+ this.expected = exp;
+ }
+
+ @Override
+ public boolean test(Object s) {
+ return expected.equals(s);
+ }
+ }
+}
diff --git a/solr/solr-ref-guide/src/basic-authentication-plugin.adoc b/solr/solr-ref-guide/src/basic-authentication-plugin.adoc
index 9b53b8b..1674c2a 100644
--- a/solr/solr-ref-guide/src/basic-authentication-plugin.adoc
+++ b/solr/solr-ref-guide/src/basic-authentication-plugin.adoc
@@ -93,6 +93,46 @@ Special care should be taken to only grant access to editing security to appropr
* Your network should, of course, be secure.
Even with Basic authentication enabled, you should not unnecessarily expose Solr to the outside world.
+== Combining Basic Authentication with Other Schemes
+:experimental:
+
+When using other authentication schemes, such as the <<jwt-authentication-plugin.adoc#,JWT Authentication Plugin>>, you may still want to use Basic authentication for a small set of "service account" oriented client applications.
+Solr provides the `MultiAuthPlugin` to support multiple authentication schemes. For example, you may want to integrate Solr with an OIDC provider for user accounts,
+but also use Basic for authenticating requests coming from the Prometheus metrics exporter. The `MultiAuthPlugin` uses the scheme of the `Authorization` header to determine which
+plugin should handle each request. The `MultiAuthPlugin` is useful when running Solr on Kubernetes as you can delegate user management and authentication to an OIDC provider for end-users,
+but also secure the liveness and readiness endpoints using `Basic` authentication, as you would not want Kubernetes to use OIDC when testing the probe endpoints.
+
+The following example illustrates how to configure the `MultiAuthPlugin` to support the `Basic` and `Bearer` schemes.
+
+[source,json]
+----
+{
+ "authentication": {
+ "class": "solr.MultiAuthPlugin",
+ "schemes": [{
+ "scheme": "bearer",
+ "blockUnknown": true,
+ "class": "solr.JWTAuthPlugin",
+ "wellKnownUrl": "https://OIDC_PROVIDER_URL/.well-known/openid-configuration",
+ "clientId": "solr",
+ "redirectUris": "http://localhost:8983/solr/",
+ "rolesClaim": "groups"
+ },{
+ "scheme": "basic",
+ "blockUnknown": true,
+ "class": "solr.BasicAuthPlugin",
+ "credentials": {
+ "k8s-oper": "PASSWORD SALT & HASH"
+ },
+ "forwardCredentials": false
+ }]
+ }
+}
+----
+For un-authenticated AJAX requests from the Solr Admin UI (i.e. requests without an `Authorization` header),
+the `MultiAuthPlugin` forwards the request to the first plugin listed in the `schemes` list. In the example above,
+users will need to authenticate to the OIDC provider to login to the Admin UI.
+
== Editing Basic Authentication Plugin Configuration
An Authentication API allows modifying user IDs and passwords.
@@ -214,6 +254,30 @@ curl --user solr:SolrRocks http://localhost:8983/api/cluster/security/authentica
====
--
+=== Edit Plugin Configuration Using the MultiAuthPlugin
+
+When using the `MultiAuthPlugin`, you need to wrap the command data with a single-keyed object that identifies the `scheme`.
+For instance, the `set-user` command for the `Basic` plugin would be:
+
+[source,json]
+----
+{
+ "set-user": {
+ "basic": {"tom":"TomIsCool", "harry":"HarrysSecret"}
+ }
+}
+----
+
+Set a property on the `Basic` plugin when using the `MultiAuthPlugin`:
+[source,json]
+----
+{
+ "set-property": {
+ "basic": {"realm":"My Solr users"}
+ }
+}
+----
+
== Using Basic Auth with SolrJ
There are two main ways to use SolrJ with Solr servers protected by basic authentication: either the permissions can be set on each individual request, or the underlying http client can be configured to add credentials to all requests that it sends.
diff --git a/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc b/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc
index 9fa29b3..4623b3d 100644
--- a/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc
+++ b/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc
@@ -191,6 +191,12 @@ It also has the benefit of working even if Solr is not started in SSL mode.
Please configure either the `trustedCerts` or `trustedCertsFile` option.
Configuring both will cause an error.
+=== Multiple Authentication Schemes
+
+Solr provides the <<basic-authentication-plugin.adoc#combining-basic-authentication-with-other-schemes,MultiAuthPlugin>> to support multiple authentication schemes based on the `Authorization` header.
+This allows you to configure Solr to delegate user management and authentication to an OIDC provider using the `JWTAuthPlugin`,
+but also allow a small set of service accounts to use `Basic` authentication when using OIDC is not supported or practical.
+
== Editing JWT Authentication Plugin Configuration
All properties mentioned above can be set or changed using the <<basic-authentication-plugin.adoc#editing-basic-authentication-plugin-configuration,Authentication API>>.
diff --git a/solr/solr-ref-guide/src/rule-based-authorization-plugin.adoc b/solr/solr-ref-guide/src/rule-based-authorization-plugin.adoc
index 40dac52..68634d6 100644
--- a/solr/solr-ref-guide/src/rule-based-authorization-plugin.adoc
+++ b/solr/solr-ref-guide/src/rule-based-authorization-plugin.adoc
@@ -89,7 +89,7 @@ s|Required |Default: none
|===
+
The authorization plugin to use.
-There are two options: `solr.RuleBasedAuthorizationPlugin` or `solr.ExternalRoleRuleBasedAuthorizationPlugin`.
+There are three options: `solr.RuleBasedAuthorizationPlugin`, `solr.ExternalRoleRuleBasedAuthorizationPlugin`, or `solr.MultiAuthRuleBasedAuthorizationPlugin`.
`permissions`::
+
@@ -226,6 +226,41 @@ Let's walk through this example:
Only requests from users having a JWT token with role "admin" will be granted the `security-edit` permission.
+=== Multiple Authorization Plugins
+
+If your `security.json` config uses the `MultiAuthPlugin`, you want to use the `MultiAuthRuleBasedAuthorizationPlugin` to use a different authorization plugin for each authentication plugin.
+
+The following example illustrates using the `MultiAuthRuleBasedAuthorizationPlugin` to configure an authorization plugin for the `Basic` and `Bearer` schemes:
+[source,json]
+----
+{
+ "authorization": {
+ "class": "solr.MultiAuthRuleBasedAuthorizationPlugin",
+ "schemes": [
+ {
+ "scheme": "basic",
+ "class": "solr.RuleBasedAuthorizationPlugin",
+ "user-role": {
+ "k8s-oper": ["k8s"]
+ }
+ },
+ {
+ "scheme": "bearer",
+ "class": "solr.ExternalRoleRuleBasedAuthorizationPlugin"
+ }
+ ],
+ "permissions": []
+ }
+}
+----
+
+It would be uncommon for the same user account to exist in both plugins.
+However, the `MultiAuthRuleBasedAuthorizationPlugin` combines the roles from all plugins together when determining the roles for a user.
+
+Users should take special care to lock down the exact set of endpoints that service accounts need access to when using Basic authentication.
+For example, if the `MultiAuthPlugin` allows a `k8s-oper` user to use Basic authentication (while all other users go through OIDC), then
+the permissions configured for the `k8s-oper` user should only allow access to specific endpoints, such as `/admin/info/system`.
+
== Permissions
Solr's Rule-Based Authorization plugin supports a flexible and powerful permission syntax.
diff --git a/solr/solrj/src/resources/apispec/cluster.security.MultiPluginAuth.Commands.json b/solr/solrj/src/resources/apispec/cluster.security.MultiPluginAuth.Commands.json
new file mode 100644
index 0000000..c656f49
--- /dev/null
+++ b/solr/solrj/src/resources/apispec/cluster.security.MultiPluginAuth.Commands.json
@@ -0,0 +1,27 @@
+{
+ "documentation": "https://lucene.apache.org/solr/guide/basic-authentication-plugin.html",
+ "description": "Modifies the configuration of the multi-auth plugin. Each command should be wrapped in a single key object that identifies the scheme. The embedded command is then passed to the scheme specific plugin.",
+ "methods": [
+ "POST"
+ ],
+ "url": {
+ "paths": [
+ "/cluster/security/authentication"
+ ]
+ },
+ "commands": {
+ "set-user": {
+ "type":"object",
+ "description": "The set-user command allows you to add users and change their passwords. Usernames and passwords are expressed as key-value pairs in a JSON object.",
+ "additionalProperties": true
+ },
+ "delete-user": {
+ "description": "Delete a user or a list of users. Passwords do not need to be provided, simply list the users in a JSON array, separated by colons.",
+ "type":"object"
+ },
+ "set-property": {
+ "type":"object",
+ "description": "The set-property command lets you set any of the configuration parameters supported by this plugin"
+ }
+ }
+}