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"
+    }
+  }
+}