This is an automated email from the ASF dual-hosted git repository.

epugh pushed a commit to branch branch_9x
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/branch_9x by this push:
     new a7fdb21cb85 SOLR-17309: Enhance certificate based authentication 
plugin with flexible cert principal resolution (#3029)
a7fdb21cb85 is described below

commit a7fdb21cb851a1db7a713f0cad31ff2cf7425476
Author: Lamine <[email protected]>
AuthorDate: Sat Mar 8 05:48:32 2025 -0600

    SOLR-17309: Enhance certificate based authentication plugin with flexible 
cert principal resolution (#3029)
    
    ---------
    
    Co-authored-by: Lamine Idjeraoui <[email protected]>
    Co-authored-by: Eric Pugh <[email protected]>
    (cherry picked from commit 9d2118bafb885f983d033c85c8a3accac11c0faa)
---
 solr/CHANGES.txt                                   |   2 +
 .../org/apache/solr/core/SolrResourceLoader.java   |   1 +
 .../org/apache/solr/security/CertAuthPlugin.java   |  86 ++++-
 .../solr/security/cert/CertPrincipalResolver.java  |  46 +++
 .../solr/security/cert/CertResolverPattern.java    | 131 +++++++
 .../org/apache/solr/security/cert/CertUtil.java    | 200 +++++++++++
 .../cert/PathBasedCertPrincipalResolver.java       | 155 ++++++++
 .../security/cert/PathBasedCertResolverBase.java   | 122 +++++++
 .../apache/solr/security/cert/package-info.java    |  19 +
 .../apache/solr/security/CertAuthPluginTest.java   |   2 +
 .../PathBasedCertPrincipalResolverTest.java        | 392 +++++++++++++++++++++
 .../pages/cert-authentication-plugin.adoc          | 389 +++++++++++++++++++-
 12 files changed, 1535 insertions(+), 10 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index d8dce619b40..cc9629262cd 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -15,6 +15,8 @@ New Features
 
 * SOLR-17656: New 'skipLeaderRecovery' replica property allows PULL replicas 
with existing indexes to immediately become ACTIVE (hossman)
 
+* SOLR-17309: Certificate based authentication plugin now has richer flexible 
cert principal resolution. (Lamine Idjeraoui via Eric Pugh)
+
 Improvements
 ---------------------
 * SOLR-15751: The v2 API now has parity with the v1 "COLSTATUS" and "segments" 
APIs, which can be used to fetch detailed information about 
diff --git a/solr/core/src/java/org/apache/solr/core/SolrResourceLoader.java 
b/solr/core/src/java/org/apache/solr/core/SolrResourceLoader.java
index 11f61a12ae2..cc275774a16 100644
--- a/solr/core/src/java/org/apache/solr/core/SolrResourceLoader.java
+++ b/solr/core/src/java/org/apache/solr/core/SolrResourceLoader.java
@@ -108,6 +108,7 @@ public class SolrResourceLoader
     "handler.admin.",
     "security.jwt.",
     "security.hadoop.",
+    "security.cert.",
     "handler.sql.",
     "hdfs.",
     "hdfs.update.",
diff --git a/solr/core/src/java/org/apache/solr/security/CertAuthPlugin.java 
b/solr/core/src/java/org/apache/solr/security/CertAuthPlugin.java
index ce59a240f91..fa8d08f70fa 100644
--- a/solr/core/src/java/org/apache/solr/security/CertAuthPlugin.java
+++ b/solr/core/src/java/org/apache/solr/security/CertAuthPlugin.java
@@ -16,17 +16,86 @@
  */
 package org.apache.solr.security;
 
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
 import java.security.cert.X509Certificate;
 import java.util.Map;
 import javax.servlet.FilterChain;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.apache.http.HttpHeaders;
+import org.apache.solr.common.util.StrUtils;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.security.cert.CertPrincipalResolver;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** An authentication plugin that sets principal based on the certificate 
subject */
 public class CertAuthPlugin extends AuthenticationPlugin {
+
+  private static final Logger log = 
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private static final String PARAM_PRINCIPAL_RESOLVER = "principalResolver";
+  private static final String PARAM_CLASS = "class";
+  private static final String PARAM_PARAMS = "params";
+
+  private static final CertPrincipalResolver DEFAULT_PRINCIPAL_RESOLVER =
+      certificate -> certificate.getSubjectX500Principal();
+  protected final CoreContainer coreContainer;
+  private CertPrincipalResolver principalResolver;
+
+  public CertAuthPlugin() {
+    this(null);
+  }
+
+  public CertAuthPlugin(CoreContainer coreContainer) {
+    this.coreContainer = coreContainer;
+  }
+
   @Override
-  public void init(Map<String, Object> pluginConfig) {}
+  public void init(Map<String, Object> pluginConfig) {
+    principalResolver =
+        resolveComponent(
+            pluginConfig,
+            PARAM_PRINCIPAL_RESOLVER,
+            CertPrincipalResolver.class,
+            DEFAULT_PRINCIPAL_RESOLVER,
+            "principalResolver");
+  }
+
+  @SuppressWarnings("unchecked")
+  private <T> T resolveComponent(
+      Map<String, Object> pluginConfig,
+      String configKey,
+      Class<T> clazz,
+      T defaultInstance,
+      String componentName) {
+    Map<String, Object> configMap = (Map<String, Object>) 
pluginConfig.get(configKey);
+    if (this.coreContainer == null) {
+      log.warn("No coreContainer configured. Using the default {}", 
componentName);
+      return defaultInstance;
+    }
+    if (configMap == null) {
+      log.warn("No {} configured. Using the default one", componentName);
+      return defaultInstance;
+    }
+
+    String className = (String) configMap.get(PARAM_CLASS);
+    if (StrUtils.isNullOrEmpty(className)) {
+      log.warn("No {} class configured. Using the default one", componentName);
+      return defaultInstance;
+    }
+    Map<String, Object> params = (Map<String, Object>) 
configMap.get(PARAM_PARAMS);
+    if (params == null) {
+      log.warn("No params found for {}. Using the default class", 
componentName);
+      return defaultInstance;
+    }
+
+    log.info("Found a {} class: {}", componentName, className);
+    return this.coreContainer
+        .getResourceLoader()
+        .newInstance(className, clazz, null, new Class<?>[] {Map.class}, new 
Object[] {params});
+  }
 
   @Override
   public boolean doAuthenticate(
@@ -35,15 +104,20 @@ public class CertAuthPlugin extends AuthenticationPlugin {
     X509Certificate[] certs =
         (X509Certificate[]) 
request.getAttribute("javax.servlet.request.X509Certificate");
     if (certs == null || certs.length == 0) {
-      numMissingCredentials.inc();
-      response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Certificate");
-      response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "require 
certificate");
-      return false;
+      return sendError(response, "require certificate");
     }
 
-    HttpServletRequest wrapped = wrapWithPrincipal(request, 
certs[0].getSubjectX500Principal());
+    HttpServletRequest wrapped =
+        wrapWithPrincipal(request, 
principalResolver.resolvePrincipal(certs[0]));
     numAuthenticated.inc();
     filterChain.doFilter(wrapped, response);
     return true;
   }
+
+  private boolean sendError(HttpServletResponse response, String msg) throws 
IOException {
+    numMissingCredentials.inc();
+    response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Certificate");
+    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, msg);
+    return false;
+  }
 }
diff --git 
a/solr/core/src/java/org/apache/solr/security/cert/CertPrincipalResolver.java 
b/solr/core/src/java/org/apache/solr/security/cert/CertPrincipalResolver.java
new file mode 100644
index 00000000000..090f3da72a9
--- /dev/null
+++ 
b/solr/core/src/java/org/apache/solr/security/cert/CertPrincipalResolver.java
@@ -0,0 +1,46 @@
+/*
+ * 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.cert;
+
+import java.security.Principal;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import javax.net.ssl.SSLPeerUnverifiedException;
+
+/**
+ * Defines the interface for resolving a {@link Principal} from an X509 
certificate. Implementations
+ * of this interface are responsible for extracting a specific piece of 
information from the
+ * certificate and converting it into a {@link Principal}.
+ */
+public interface CertPrincipalResolver {
+  /**
+   * Resolves a {@link Principal} from the given X509 certificate.
+   *
+   * <p>This method is intended to extract principal information, such as a 
common name (CN) or an
+   * email address, from the specified certificate and encapsulate it into a 
{@link Principal}
+   * object. The specific field or attribute of the certificate to be used as 
the principal, and the
+   * logic for its extraction, is defined by the implementation.
+   *
+   * @param certificate The X509Certificate from which to resolve the 
principal.
+   * @return A {@link Principal} object representing the resolved principal 
from the certificate.
+   * @throws SSLPeerUnverifiedException If the peer's identity has not been 
verified.
+   * @throws CertificateParsingException If an error occurs while parsing the 
certificate for
+   *     principal information.
+   */
+  Principal resolvePrincipal(X509Certificate certificate)
+      throws SSLPeerUnverifiedException, CertificateParsingException;
+}
diff --git 
a/solr/core/src/java/org/apache/solr/security/cert/CertResolverPattern.java 
b/solr/core/src/java/org/apache/solr/security/cert/CertResolverPattern.java
new file mode 100644
index 00000000000..802743a516d
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/security/cert/CertResolverPattern.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.solr.security.cert;
+
+import java.lang.invoke.MethodHandles;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Represents a pattern for resolving certificate information, specifying the 
criteria for
+ * extracting and matching values from certificates.
+ */
+public class CertResolverPattern {
+
+  private static final Logger log = 
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final String name;
+  private final String path;
+  private final CheckType checkType;
+  private final Set<String> filterValues;
+
+  /**
+   * Constructs a CertResolverPattern with specified parameters.
+   *
+   * @param name The name associated with this pattern.
+   * @param path The certificate field path this pattern applies to.
+   * @param checkType The type of check to perform on extracted values.
+   * @param filterValues The set of values to check against the extracted 
certificate field.
+   */
+  public CertResolverPattern(String name, String path, String checkType, 
Set<String> filterValues) {
+    this.name = name;
+    this.path = path;
+    this.checkType = CheckType.fromString(checkType);
+    this.filterValues = filterValues;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public String getPath() {
+    return path;
+  }
+
+  public CheckType getCheckType() {
+    return checkType;
+  }
+
+  public Set<String> getFilterValues() {
+    return filterValues;
+  }
+
+  public static boolean matchesPattern(String value, CertResolverPattern 
pattern) {
+    return matchesPattern(value, pattern.getCheckType(), 
pattern.getFilterValues());
+  }
+
+  /**
+   * Determines if a given value matches the specified filter values, 
depending on the check type.
+   *
+   * @param value The value to check.
+   * @param checkType The type of check to perform.
+   * @param values The set of values to check against.
+   * @return True if the value matches the criteria; false otherwise.
+   */
+  public static boolean matchesPattern(String value, CheckType checkType, 
Set<String> values) {
+    log.debug("matchesPattern   value:{}   checkType:{}    values:{}", value, 
checkType, values);
+    String lowerValue =
+        value.toLowerCase(Locale.ROOT); // lowercase for case-insensitive 
comparisons
+    switch (checkType) {
+      case EQUALS:
+        return values.contains(lowerValue);
+      case STARTS_WITH:
+        return values.stream().anyMatch(lowerValue::startsWith);
+      case ENDS_WITH:
+        return values.stream().anyMatch(lowerValue::endsWith);
+      case CONTAINS:
+        return values.stream().anyMatch(lowerValue::contains);
+      case WILDCARD:
+        return true;
+      default:
+        return false;
+    }
+  }
+
+  /** Enum defining the types of checks that can be performed on extracted 
certificate values. */
+  public enum CheckType {
+    STARTS_WITH,
+    ENDS_WITH,
+    CONTAINS,
+    EQUALS,
+    WILDCARD;
+    // TODO: add regex support
+
+    private static final Map<String, CheckType> lookup =
+        Map.of(
+            "equals", EQUALS,
+            "startswith", STARTS_WITH,
+            "endswith", ENDS_WITH,
+            "contains", CONTAINS,
+            "*", WILDCARD,
+            "wildcard", WILDCARD);
+
+    public static CheckType fromString(String checkType) {
+      if (checkType == null) {
+        throw new IllegalArgumentException("CheckType cannot be null");
+      }
+      CheckType result = lookup.get(checkType.toLowerCase(Locale.ROOT));
+      if (result == null) {
+        throw new IllegalArgumentException("No CheckType with text '" + 
checkType + "' found");
+      }
+      return result;
+    }
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/security/cert/CertUtil.java 
b/solr/core/src/java/org/apache/solr/security/cert/CertUtil.java
new file mode 100644
index 00000000000..5a4e06bbe61
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/security/cert/CertUtil.java
@@ -0,0 +1,200 @@
+/*
+ * 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.cert;
+
+import java.lang.invoke.MethodHandles;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import javax.naming.InvalidNameException;
+import javax.naming.ldap.LdapName;
+import javax.naming.ldap.Rdn;
+import javax.security.auth.x500.X500Principal;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility class for certificate-related operations, including extracting 
fields from the subject or
+ * issuer DN and SAN fields from X509 certificates.
+ */
+public class CertUtil {
+
+  private static final Logger log = 
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  public static final String SUBJECT_DN_PREFIX = "subject.dn";
+  public static final String ISSUER_DN_PREFIX = "issuer.dn";
+  public static final String SAN_PREFIX = "san.";
+
+  /**
+   * Extracts a specified field or the entire DN from an X500Principal, such 
as a certificate's
+   * subject or issuer. If the entire DN is returned the format would be 
RFC2253
+   *
+   * @param principal The X500Principal from which to extract information.
+   * @param path The DN field to extract, or a prefix indicating the entire DN.
+   * @return The value of the specified field, or the entire DN if just a 
prefix is provided.
+   */
+  public static Optional<String> extractFieldFromX500Principal(
+      X500Principal principal, String path) {
+    if (principal == null || path == null || path.isEmpty()) {
+      return Optional.empty();
+    }
+
+    String dn = principal.getName(X500Principal.RFC2253);
+
+    try {
+      final LdapName ln = new LdapName(dn);
+      // Path does not specify a field, return the whole DN after normalizing 
it
+      if (path.equalsIgnoreCase(SUBJECT_DN_PREFIX) || 
path.equalsIgnoreCase(ISSUER_DN_PREFIX)) {
+        // Remove whitespaces around RDNs, sort them, and reconstruct the DN 
string
+        List<String> rdns =
+            ln.getRdns().stream()
+                .map(rdn -> rdn.getType().trim() + "=" + 
rdn.getValue().toString().trim())
+                .sorted()
+                .collect(Collectors.toList());
+        dn = String.join(",", rdns);
+        return Optional.of(dn);
+      }
+
+      // Extract and return the specified DN field value
+      String field = null;
+      if (path.startsWith(SUBJECT_DN_PREFIX + ".")) {
+        field = path.substring((SUBJECT_DN_PREFIX + ".").length());
+      } else if (path.startsWith(ISSUER_DN_PREFIX + ".")) {
+        field = path.substring((ISSUER_DN_PREFIX + ".").length());
+      }
+
+      if (field != null) {
+        String fieldF = field;
+        return ln.getRdns().stream()
+            .filter(rdn -> rdn.getType().equalsIgnoreCase(fieldF))
+            .findFirst()
+            .map(Rdn::getValue)
+            .map(Object::toString);
+      }
+    } catch (InvalidNameException e) {
+      log.warn("Invalid DN in LdapName instantiation. DN={}", dn);
+    }
+    return Optional.empty();
+  }
+
+  /**
+   * Extracts a specified field or the entire subject DN from an X509 
certificate.
+   *
+   * @param certificate The certificate from which to extract the subject DN 
information.
+   * @param path The path specifying the subject DN field to extract or a 
prefix for the entire DN.
+   * @return An Optional containing the value of the specified subject DN 
field or the entire DN;
+   *     empty if not found.
+   */
+  public static Optional<String> extractFromSubjectDN(X509Certificate 
certificate, String path) {
+    return 
extractFieldFromX500Principal(certificate.getSubjectX500Principal(), path);
+  }
+
+  /**
+   * Extracts a specified field or the entire issuer DN from an X509 
certificate.
+   *
+   * @param certificate The certificate from which to extract the issuer DN 
information.
+   * @param path The path specifying the issuer DN field to extract or a 
prefix for the entire DN.
+   * @return An Optional containing the value of the specified issuer DN field 
or the entire DN;
+   *     empty if not found.
+   */
+  public static Optional<String> extractFromIssuerDN(X509Certificate 
certificate, String path) {
+    return extractFieldFromX500Principal(certificate.getIssuerX500Principal(), 
path);
+  }
+
+  /**
+   * Extracts SAN (Subject Alternative Name) fields from an X509 certificate 
that match a specified
+   * path and predicate.
+   *
+   * @param certificate The certificate from which to extract SAN information.
+   * @param path The path specifying the SAN field to extract.
+   * @param valueMatcher A predicate to apply to each SAN value for filtering.
+   * @return An Optional containing a list of SAN values that match the 
specified path and
+   *     predicate; empty if none found.
+   * @throws CertificateParsingException If an error occurs while parsing the 
certificate for SAN
+   *     fields.
+   */
+  public static Optional<List<String>> extractFromSAN(
+      X509Certificate certificate, String path, Predicate<String> valueMatcher)
+      throws CertificateParsingException {
+    Collection<List<?>> altNames = certificate.getSubjectAlternativeNames();
+    if (altNames == null) {
+      return Optional.empty();
+    }
+    List<String> filteredSANValues =
+        altNames.stream()
+            .filter(entry -> entry != null && entry.size() >= 2)
+            .filter(
+                entry -> {
+                  SANType sanType = SANType.fromValue((Integer) entry.get(0));
+                  return sanType != null && 
sanType.pathLowerCase().equals(path);
+                })
+            .map(entry -> (String) entry.get(1))
+            .filter(valueMatcher)
+            .collect(Collectors.toList());
+
+    return Optional.of(filteredSANValues);
+  }
+
+  /**
+   * Supported SAN (Subject Alternative Name) types as defined in <a
+   * href="https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6";>RFC 
5280</a>
+   */
+  public enum SANType {
+    OTHER_NAME(0), // OtherName
+    EMAIL(1), // rfc822Name
+    DNS(2), // dNSName
+    X400_ADDRESS(3), // x400Address
+    DIRECTORY_NAME(4), // directoryName
+    EDI_PARTY_NAME(5), // ediPartyName
+    URI(6), // uniformResourceIdentifier
+    IP_ADDRESS(7), // iPAddress
+    REGISTERED_ID(8); // registeredID
+
+    private static final Map<Integer, SANType> lookup =
+        EnumSet.allOf(SANType.class).stream()
+            .collect(Collectors.toMap(SANType::getValue, sanType -> sanType));
+
+    private final int value;
+
+    SANType(int value) {
+      this.value = value;
+    }
+
+    public int getValue() {
+      return value;
+    }
+
+    public static SANType fromValue(int value) {
+      return lookup.get(value);
+    }
+
+    public String path() {
+      return "SAN." + name();
+    }
+
+    public String pathLowerCase() {
+      return path().toLowerCase(Locale.ROOT);
+    }
+  }
+}
diff --git 
a/solr/core/src/java/org/apache/solr/security/cert/PathBasedCertPrincipalResolver.java
 
b/solr/core/src/java/org/apache/solr/security/cert/PathBasedCertPrincipalResolver.java
new file mode 100644
index 00000000000..24640c0685e
--- /dev/null
+++ 
b/solr/core/src/java/org/apache/solr/security/cert/PathBasedCertPrincipalResolver.java
@@ -0,0 +1,155 @@
+/*
+ * 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.cert;
+
+import java.lang.invoke.MethodHandles;
+import java.security.Principal;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import org.apache.http.auth.BasicUserPrincipal;
+import org.apache.solr.common.SolrException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Implements a {@link CertPrincipalResolver} that resolves a {@link 
Principal} from an X509
+ * certificate based on configurable paths, filters, and optional extraction 
patterns. This resolver
+ * can extract principal information from various certificate fields, such as 
Subject DN or SAN
+ * fields, according to the specified path. Additionally, it can further 
refine the extracted value
+ * based on optional "after" and "before" patterns, allowing for more precise 
control over the
+ * principal value.
+ *
+ * <p>Example configuration without extraction pattern:
+ *
+ * <pre>{@code
+ * "principalResolver": {
+ *   "class":"solr.PathBasedCertPrincipalResolver",
+ *   "params": {
+ *     "path":"SAN.email",
+ *     "filter":{
+ *       "checkType":"startsWith",
+ *       "values":["user@example"]
+ *     }
+ *   }
+ * }
+ * }</pre>
+ *
+ * In this configuration, the resolver is directed to extract email addresses 
from the SAN (Subject
+ * Alternative Name) field of the certificate and use them as principal names 
if they match the
+ * specified filter criteria.
+ *
+ * <p>Example configuration with extraction pattern:
+ *
+ * <pre>{@code
+ * "principalResolver": {
+ *   "class":"solr.PathBasedCertPrincipalResolver",
+ *   "params": {
+ *     "path":"SAN.email",
+ *     "filter":{
+ *       "checkType":"startsWith",
+ *       "values":["email_user1@example"]
+ *     },
+ *     "extract": {
+ *       "after":"_",
+ *       "before":"@"
+ *     }
+ *   }
+ * }
+ * }</pre>
+ *
+ * In this extended configuration, after extracting email addresses that match 
the filter criteria,
+ * the resolver further processes the extracted value to include only the 
portion after the "_"
+ * symbol and before "@". This allows for extracting specific parts of the 
principal value,
+ * providing additional flexibility and control.
+ */
+public class PathBasedCertPrincipalResolver extends PathBasedCertResolverBase
+    implements CertPrincipalResolver {
+
+  private static final Logger log = 
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private static final String PARAM_EXTRACT = "extract";
+  private static final String PARAM_AFTER = "after";
+  private static final String PARAM_BEFORE = "before";
+
+  private final CertResolverPattern pattern;
+  private final String startPattern;
+  private final String endPattern;
+
+  /**
+   * Constructs a new PathBasedCertPrincipalResolver with the specified 
configuration parameters.
+   *
+   * @param params The configuration parameters specifying the path and filter 
for extracting
+   *     principal information from certificates.
+   */
+  @SuppressWarnings("unchecked")
+  public PathBasedCertPrincipalResolver(Map<String, Object> params) {
+    this.pattern = createCertResolverPattern(params, 
CertUtil.SUBJECT_DN_PREFIX);
+    Map<String, String> extractConfig =
+        (Map<String, String>) params.getOrDefault(PARAM_EXTRACT, 
Collections.emptyMap());
+    this.startPattern = extractConfig.getOrDefault(PARAM_AFTER, "");
+    this.endPattern = extractConfig.getOrDefault(PARAM_BEFORE, "");
+  }
+
+  /**
+   * Resolves the principal from the given X509 certificate based on the 
configured path and filter.
+   * The first matching value, if any, is used as the principal name.
+   *
+   * @param certificate The X509Certificate from which to resolve the 
principal.
+   * @return A {@link Principal} object representing the resolved principal 
from the certificate.
+   * @throws SSLPeerUnverifiedException If the SSL peer is not verified.
+   * @throws CertificateParsingException If parsing the certificate fails.
+   */
+  @Override
+  public Principal resolvePrincipal(X509Certificate certificate)
+      throws SSLPeerUnverifiedException, CertificateParsingException {
+    Map<String, Set<String>> matches =
+        getValuesFromPaths(certificate, Collections.singletonList(pattern));
+    String basePrincipal = null;
+    if (matches != null && !matches.isEmpty()) {
+      Set<String> fieldValues = matches.getOrDefault(pattern.getName(), 
Collections.emptySet());
+      basePrincipal = fieldValues.stream().findFirst().orElse(null);
+    }
+
+    log.debug("Resolved basePrincipal: {}", basePrincipal);
+    if (basePrincipal == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "Resolved principal is null. "
+              + "No principal information was found matching the 
configuration");
+    }
+
+    String principal = extractPrincipal(basePrincipal);
+    log.debug("Resolved principal: {}", principal);
+    return new BasicUserPrincipal(principal);
+  }
+
+  private String extractPrincipal(String str) {
+    int start =
+        startPattern.isEmpty() || !str.contains(startPattern)
+            ? 0
+            : str.indexOf(startPattern) + startPattern.length();
+    int end = endPattern.isEmpty() ? str.length() : str.indexOf(endPattern, 
start);
+    if (start >= 0 && end > start && end <= str.length()) {
+      str = str.substring(start, end);
+    }
+    return str;
+  }
+}
diff --git 
a/solr/core/src/java/org/apache/solr/security/cert/PathBasedCertResolverBase.java
 
b/solr/core/src/java/org/apache/solr/security/cert/PathBasedCertResolverBase.java
new file mode 100644
index 00000000000..486a1193b8b
--- /dev/null
+++ 
b/solr/core/src/java/org/apache/solr/security/cert/PathBasedCertResolverBase.java
@@ -0,0 +1,122 @@
+/*
+ * 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.cert;
+
+import static org.apache.solr.security.cert.CertResolverPattern.matchesPattern;
+import static org.apache.solr.security.cert.CertUtil.ISSUER_DN_PREFIX;
+import static org.apache.solr.security.cert.CertUtil.SAN_PREFIX;
+import static org.apache.solr.security.cert.CertUtil.SUBJECT_DN_PREFIX;
+
+import java.lang.invoke.MethodHandles;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Abstract base class for implementing path-based certificate resolvers. This 
class provides common
+ * functionality for extracting and processing certificate information based 
on configurable paths
+ * and filters.
+ */
+public abstract class PathBasedCertResolverBase {
+
+  private static final Logger log = 
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private static final String PARAM_NAME = "name";
+  private static final String PARAM_PATH = "path";
+  private static final String PARAM_FILTER = "filter";
+  private static final String PARAM_FILTER_CHECK_TYPE = "checkType";
+  private static final String PARAM_FILTER_VALUES = "values";
+
+  /**
+   * Creates a {@link CertResolverPattern} from a given configuration map and 
a default path. The
+   * pattern defines how certificate information should be extracted and 
processed.
+   *
+   * @param config The configuration map containing resolver settings.
+   * @param defaultPath The default path to use if none is specified in the 
configuration.
+   * @return A new instance of {@link CertResolverPattern} based on the 
provided configuration.
+   */
+  @SuppressWarnings("unchecked")
+  protected CertResolverPattern createCertResolverPattern(
+      Map<String, Object> config, String defaultPath) {
+    String path = ((String) config.getOrDefault(PARAM_PATH, 
defaultPath)).toLowerCase(Locale.ROOT);
+    String name = ((String) config.getOrDefault(PARAM_NAME, 
path)).toLowerCase(Locale.ROOT);
+    Map<String, Object> filter =
+        (Map<String, Object>) config.getOrDefault(PARAM_FILTER, 
Collections.emptyMap());
+    String checkType =
+        (String)
+            filter.getOrDefault(
+                PARAM_FILTER_CHECK_TYPE, 
CertResolverPattern.CheckType.WILDCARD.toString());
+    List<String> values =
+        (List<String>) filter.getOrDefault(PARAM_FILTER_VALUES, 
Collections.emptyList());
+    Set<String> lowerCaseValues =
+        values.stream().map(value -> 
value.toLowerCase(Locale.ROOT)).collect(Collectors.toSet());
+    return new CertResolverPattern(name, path, checkType, lowerCaseValues);
+  }
+
+  /**
+   * Extracts values from specified paths in a certificate and organizes them 
into a map. The map's
+   * keys are derived from the names specified in the patterns, and the values 
are sets of strings
+   * extracted from the certificate according to the pattern definitions.
+   *
+   * @param certificate The X509Certificate from which to extract information.
+   * @param patterns A list of {@link CertResolverPattern} objects defining 
the extraction criteria.
+   * @return A map where each key is a pattern name and each value is a set of 
extracted strings.
+   * @throws CertificateParsingException If an error occurs while parsing the 
certificate.
+   */
+  protected Map<String, Set<String>> getValuesFromPaths(
+      X509Certificate certificate, List<CertResolverPattern> patterns)
+      throws CertificateParsingException {
+    Map<String, Set<String>> fieldValuesMap = new HashMap<>();
+    for (CertResolverPattern pattern : patterns) {
+      String path = pattern.getPath();
+      if (path.startsWith(SUBJECT_DN_PREFIX) || 
path.startsWith(ISSUER_DN_PREFIX)) {
+        Optional<String> value =
+            path.startsWith(SUBJECT_DN_PREFIX)
+                ? CertUtil.extractFromSubjectDN(certificate, path)
+                : CertUtil.extractFromIssuerDN(certificate, path);
+        value.ifPresent(
+            val ->
+                fieldValuesMap
+                    .computeIfAbsent(pattern.getName(), k -> new 
LinkedHashSet<>())
+                    .add(val));
+      } else if (path.startsWith(SAN_PREFIX)) {
+        Optional<List<String>> sanValues =
+            CertUtil.extractFromSAN(certificate, path, value -> 
matchesPattern(value, pattern));
+        sanValues.ifPresent(
+            values ->
+                fieldValuesMap
+                    .computeIfAbsent(pattern.getName(), k -> new 
LinkedHashSet<>())
+                    .addAll(values));
+      } else {
+        throw new IllegalArgumentException(
+            "Invalid path in the certificate resolver pattern: " + path);
+      }
+    }
+    log.debug("Extracted field values: {}", fieldValuesMap);
+    return fieldValuesMap;
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/security/cert/package-info.java 
b/solr/core/src/java/org/apache/solr/security/cert/package-info.java
new file mode 100644
index 00000000000..ab4eed56c0f
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/security/cert/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * 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.
+ */
+
+/** Certificate based authentication classes */
+package org.apache.solr.security.cert;
diff --git 
a/solr/core/src/test/org/apache/solr/security/CertAuthPluginTest.java 
b/solr/core/src/test/org/apache/solr/security/CertAuthPluginTest.java
index c961b1775b8..b42ce94dcd1 100644
--- a/solr/core/src/test/org/apache/solr/security/CertAuthPluginTest.java
+++ b/solr/core/src/test/org/apache/solr/security/CertAuthPluginTest.java
@@ -24,6 +24,7 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import java.security.cert.X509Certificate;
+import java.util.Collections;
 import javax.security.auth.x500.X500Principal;
 import javax.servlet.FilterChain;
 import javax.servlet.http.HttpServletRequest;
@@ -46,6 +47,7 @@ public class CertAuthPluginTest extends SolrTestCaseJ4 {
   public void setUp() throws Exception {
     super.setUp();
     plugin = new CertAuthPlugin();
+    plugin.init(Collections.emptyMap());
   }
 
   @Test
diff --git 
a/solr/core/src/test/org/apache/solr/security/PathBasedCertPrincipalResolverTest.java
 
b/solr/core/src/test/org/apache/solr/security/PathBasedCertPrincipalResolverTest.java
new file mode 100644
index 00000000000..1911ff66d6b
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/security/PathBasedCertPrincipalResolverTest.java
@@ -0,0 +1,392 @@
+/*
+ * 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 static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.carrotsearch.randomizedtesting.RandomizedRunner;
+import java.security.Principal;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.security.auth.x500.X500Principal;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.security.cert.CertResolverPattern;
+import org.apache.solr.security.cert.CertUtil.SANType;
+import org.apache.solr.security.cert.PathBasedCertPrincipalResolver;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(RandomizedRunner.class)
+public class PathBasedCertPrincipalResolverTest extends SolrTestCaseJ4 {
+
+  private static final String SUBJECT_DN =
+      " CN=John Doe, O=Solr Corp, OU= Engineering, C=US , ST =California, L 
=San Francisco"; // whitespaces should be ignored
+  private static final String ISSUER_DN =
+      "CN = Issuer, O= Issuer Corp, OU =IT, C=US , ST=California, L =San 
Francisco ";
+
+  private X509Certificate mockCertificate;
+
+  @BeforeClass
+  public static void setupMockito() {
+    SolrTestCaseJ4.assumeWorkingMockito();
+  }
+
+  @Override
+  @Before
+  public void setUp() throws Exception {
+    super.setUp();
+    mockCertificate = mock(X509Certificate.class);
+  }
+
+  @Test
+  public void testSubjectDn() throws SSLPeerUnverifiedException, 
CertificateParsingException {
+    // CN
+    testCertificateCases(SUBJECT_DN, "subject.dn.CN", "John Doe");
+
+    // O
+    testCertificateCases(SUBJECT_DN, "subject.dn.O", "Solr Corp");
+
+    // OU
+    testCertificateCases(SUBJECT_DN, "subject.dn.OU", "Engineering");
+
+    // C
+    testCertificateCases(SUBJECT_DN, "subject.dn.C", "US");
+
+    // ST
+    testCertificateCases(SUBJECT_DN, "subject.dn.ST", "California");
+
+    // L
+    testCertificateCases(SUBJECT_DN, "subject.dn.L", "San Francisco");
+  }
+
+  @Test
+  public void testIssuerDn() throws SSLPeerUnverifiedException, 
CertificateParsingException {
+    // CN
+    testCertificateCases(ISSUER_DN, "issuer.dn.CN", "Issuer");
+
+    // O
+    testCertificateCases(ISSUER_DN, "issuer.dn.O", "Issuer Corp");
+
+    // OU
+    testCertificateCases(ISSUER_DN, "issuer.dn.OU", "IT");
+
+    // C
+    testCertificateCases(ISSUER_DN, "issuer.dn.C", "US");
+
+    // ST
+    testCertificateCases(ISSUER_DN, "issuer.dn.ST", "California");
+
+    // L
+    testCertificateCases(ISSUER_DN, "issuer.dn.L", "San Francisco");
+  }
+
+  @Test
+  public void testSan() {
+    // Email
+    testCertificateCases(
+        List.of(
+            List.of(SANType.EMAIL.getValue(), "[email protected]"),
+            List.of(SANType.EMAIL.getValue(), "[email protected]")),
+        "san.email",
+        "[email protected]");
+
+    // Email with 'extract'
+    testCertificateCases(
+        List.of(List.of(SANType.EMAIL.getValue(), "[email protected]")),
+        null,
+        "san.email",
+        List.of("[email protected]"),
+        "startsWith",
+        Map.of("after", "@", "before", ".com"),
+        "example");
+    testCertificateCases(
+        List.of(List.of(SANType.EMAIL.getValue(), "[email protected]")),
+        null,
+        "san.email",
+        List.of("[email protected]"),
+        "equals",
+        Map.of("before", "@"),
+        "user");
+
+    // DNS
+    testCertificateCases(
+        List.of(
+            List.of(SANType.DNS.getValue(), "value1.example.com"),
+            List.of(SANType.DNS.getValue(), "value2.example.com")),
+        "san.dns",
+        "value1.example.com");
+
+    // URI
+    testCertificateCases(
+        List.of(
+            List.of(SANType.URI.getValue(), "http://example.com";),
+            List.of(SANType.URI.getValue(), "http://example2.org";)),
+        "san.uri",
+        List.of("http://example.com";),
+        "equals",
+        "http://example.com";);
+
+    // IP Address
+    testCertificateCases(
+        List.of(
+            List.of(SANType.IP_ADDRESS.getValue(), "192.168.1.1"),
+            List.of(SANType.IP_ADDRESS.getValue(), "10.0.0.1")),
+        "san.IP_ADDRESS",
+        "192.168.1.1");
+
+    // OTHER_NAME
+    testCertificateCases(
+        List.of(List.of(SANType.OTHER_NAME.getValue(), "1.2.3.4")), 
"san.OTHER_NAME", "1.2.3.4");
+
+    // X400_ADDRESS
+    testCertificateCases(
+        List.of(List.of(SANType.X400_ADDRESS.getValue(), "X400AddressValue")),
+        "san.X400_ADDRESS",
+        "X400AddressValue");
+
+    // DIRECTORY_NAME
+    testCertificateCases(
+        List.of(List.of(SANType.DIRECTORY_NAME.getValue(), 
"DirectoryNameValue")),
+        "san.DIRECTORY_NAME",
+        "DirectoryNameValue");
+
+    // EDI_PARTY_NAME
+    testCertificateCases(
+        List.of(List.of(SANType.EDI_PARTY_NAME.getValue(), 
"EdiPartyNameValue")),
+        "san.EDI_PARTY_NAME",
+        "EdiPartyNameValue");
+
+    // REGISTERED_ID
+    testCertificateCases(
+        List.of(List.of(SANType.REGISTERED_ID.getValue(), 
"RegisteredIdValue")),
+        "san.REGISTERED_ID",
+        "RegisteredIdValue");
+
+    //  CheckType tests
+    testCertificateCases(
+        List.of(
+            List.of(SANType.EMAIL.getValue(), "[email protected]"),
+            List.of(SANType.EMAIL.getValue(), "[email protected]")),
+        "san.email",
+        List.of("[email protected]"),
+        "equals",
+        "[email protected]");
+
+    testCertificateCases(
+        List.of(
+            List.of(SANType.EMAIL.getValue(), "[email protected]"),
+            List.of(SANType.EMAIL.getValue(), "[email protected]")),
+        "san.email",
+        List.of("user2"),
+        "contains",
+        "[email protected]");
+
+    testCertificateCases(
+        List.of(
+            List.of(SANType.EMAIL.getValue(), "[email protected]"),
+            List.of(SANType.EMAIL.getValue(), "[email protected]")),
+        "san.email",
+        List.of("user2"),
+        "startsWith",
+        "[email protected]");
+
+    testCertificateCases(
+        List.of(
+            List.of(SANType.EMAIL.getValue(), "[email protected]"),
+            List.of(SANType.EMAIL.getValue(), "[email protected]")),
+        "san.email",
+        List.of("example2.com"),
+        "endsWith",
+        "[email protected]");
+
+    testCertificateCases(
+        List.of(
+            List.of(SANType.EMAIL.getValue(), "[email protected]"),
+            List.of(SANType.EMAIL.getValue(), "[email protected]")),
+        "san.email",
+        Collections.emptyList(),
+        "*",
+        "[email protected]");
+  }
+
+  @Test
+  public void testMultipleSANEntries()
+      throws SSLPeerUnverifiedException, CertificateParsingException {
+
+    when(mockCertificate.getSubjectAlternativeNames())
+        .thenReturn(
+            List.of(
+                List.of(SANType.EMAIL.getValue(), "[email protected]"),
+                List.of(SANType.EMAIL.getValue(), "[email protected]")));
+
+    Map<String, Object> params =
+        Map.of(
+            "path",
+            "SAN.email",
+            "filter",
+            Map.of(
+                "checkType",
+                "equals",
+                "values",
+                List.of("[email protected]", "[email protected]")));
+
+    PathBasedCertPrincipalResolver resolver = new 
PathBasedCertPrincipalResolver(params);
+    Principal result = resolver.resolvePrincipal(mockCertificate);
+
+    assertNotNull(result);
+    assertTrue(
+        result.getName().contains("[email protected]")
+            || result.getName().contains("[email protected]"));
+  }
+
+  @Test
+  public void testResolverWithInvalidPath() {
+    Map<String, Object> params =
+        Map.of(
+            "path",
+            "Invalid.path",
+            "filter",
+            Map.of("checkType", "equals", "values", List.of("value1", 
"value2")));
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> new 
PathBasedCertPrincipalResolver(params).resolvePrincipal(mockCertificate));
+  }
+
+  @Test
+  public void testNoMatchFound() {
+    Map<String, Object> params =
+        Map.of(
+            "path",
+            "subject.dn.CN",
+            "filter",
+            Map.of("checkType", "equals", "values", List.of("NonExistent")));
+
+    PathBasedCertPrincipalResolver resolver = new 
PathBasedCertPrincipalResolver(params);
+    assertThrows(SolrException.class, () -> 
resolver.resolvePrincipal(mockCertificate));
+  }
+
+  @Test
+  public void testResolverWithExtractPatternMissing()
+      throws SSLPeerUnverifiedException, CertificateParsingException {
+    when(mockCertificate.getSubjectAlternativeNames())
+        .thenReturn(List.of(List.of(SANType.EMAIL.getValue(), 
"[email protected]")));
+
+    // Both 'after' and 'before' patterns are missing
+    final Map<String, Object> params = new HashMap<>();
+    params.put("path", "SAN.email");
+    params.put("filter", Map.of("checkType", "startsWith", "values", 
List.of("info@")));
+    assertResolvedPrincipal(params, "[email protected]");
+
+    // Only 'before' pattern is provided, 'after' is missing
+    params.put("extract", Map.of("before", ".com"));
+    assertResolvedPrincipal(params, "info@example");
+
+    // Only 'after' pattern is provided, 'before' is missing
+    params.put("extract", Map.of("after", "@"));
+    assertResolvedPrincipal(params, "example.com");
+
+    // Invalid 'after' pattern that doesn't exist in the SAN value
+    params.put("extract", Map.of("after", "notfound"));
+    assertResolvedPrincipal(
+        params,
+        "[email protected]"); // Expect the original value since the 'after' 
pattern was not found
+  }
+
+  private void testCertificateCases(List<List<?>> sanData, String path, String 
expectedValue) {
+
+    testCertificateCases(
+        sanData,
+        null,
+        path,
+        Collections.emptyList(),
+        CertResolverPattern.CheckType.WILDCARD.toString(),
+        Collections.emptyMap(),
+        expectedValue);
+  }
+
+  private void testCertificateCases(
+      List<List<?>> sanData,
+      String path,
+      List<String> filterValues,
+      String filterCheckType,
+      String expectedValue) {
+
+    testCertificateCases(
+        sanData, null, path, filterValues, filterCheckType, 
Collections.emptyMap(), expectedValue);
+  }
+
+  private void testCertificateCases(String dn, String path, String 
expectedValue) {
+
+    testCertificateCases(
+        null,
+        dn,
+        path,
+        Collections.emptyList(),
+        CertResolverPattern.CheckType.WILDCARD.toString(),
+        Collections.emptyMap(),
+        expectedValue);
+  }
+
+  private void testCertificateCases(
+      List<List<?>> sanData,
+      String dn,
+      String path,
+      List<String> filterValues,
+      String filterCheckType,
+      Map<String, String> extractPatterns,
+      String expectedValue) {
+    try {
+      if (path.startsWith("san")) {
+        when(mockCertificate.getSubjectAlternativeNames()).thenReturn(sanData);
+      } else if (path.startsWith("subject.dn")) {
+        X500Principal subjectDN = new X500Principal(dn);
+        when(mockCertificate.getSubjectX500Principal()).thenReturn(subjectDN);
+      } else if (path.startsWith("issuer.dn")) {
+        X500Principal issuerDN = new X500Principal(dn);
+        when(mockCertificate.getIssuerX500Principal()).thenReturn(issuerDN);
+      }
+
+      Map<String, Object> params = new HashMap<>();
+      params.put("path", path);
+      params.put("filter", Map.of("checkType", filterCheckType, "values", 
filterValues));
+      params.put("extract", extractPatterns);
+
+      assertResolvedPrincipal(params, expectedValue);
+    } catch (CertificateParsingException | SSLPeerUnverifiedException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private void assertResolvedPrincipal(Map<String, Object> params, String 
expectedPrincipalName)
+      throws SSLPeerUnverifiedException, CertificateParsingException {
+    PathBasedCertPrincipalResolver resolver = new 
PathBasedCertPrincipalResolver(params);
+    Principal result = resolver.resolvePrincipal(mockCertificate);
+
+    assertNotNull(result);
+    assertEquals(expectedPrincipalName, result.getName());
+  }
+}
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/pages/cert-authentication-plugin.adoc
 
b/solr/solr-ref-guide/modules/deployment-guide/pages/cert-authentication-plugin.adoc
index 681388e21bf..318091a6106 100644
--- 
a/solr/solr-ref-guide/modules/deployment-guide/pages/cert-authentication-plugin.adoc
+++ 
b/solr/solr-ref-guide/modules/deployment-guide/pages/cert-authentication-plugin.adoc
@@ -40,10 +40,21 @@ These checks are described in the xref:enabling-ssl.adoc[] 
section.
 
 This plugin provides no additional checking beyond what has been configured 
via SSL properties.
 
-=== User Principal Extraction
+It is best practice to verify the actual contents of certificates issued by 
your trusted certificate authority before configuring authorization based on 
the contents.
+
+== User Principal Extraction
+
+This plugin will configure the user `principal` for the request based on the 
contents of the client certificate. Solr provides three methods for extracting 
the `principal`:
+
+*Simple Resolution*: Uses the full X500 subject name from the client 
certificate. This is the default behavior.
+
+*Path Based Resolution*: Offers greater flexibility by allowing to extract 
specific fields or patterns using the 
{solr-javadocs}/core/org/apache/solr/security/cert/PathBasedCertPrincipalResolver.html[PathBasedCertPrincipalResolver].
 Here "`paths`" refers to the fields ("`.`" separated) defined in the 
certificate.
+
+*Custom Resolution*: If the built-in options do not meet your needs, you can 
add a custom class by implementing the 
{solr-javadocs}/core/org/apache/solr/security/cert/CertPrincipalResolver.html[`CertPrincipalResolver`]interface.
 This allows you to define your own logic for extracting and processing the 
`principal`. The custom implementation can then be referenced in your 
`security.json` configuration.
 
-This plugin will configure the user principal for the request based on the 
X500 subject present in the client certificate.
-Authorization plugins will need to accept and handle the full subject name, 
for example:
+=== Simple Principal Resolution
+
+By default, Solr uses the full subject name as the `principal`. For example:
 
 [source,text]
 ----
@@ -53,7 +64,377 @@ CN=Solr User,OU=Engineering,O=Example Inc.,C=US
 A list of possible tags that can be present in the subject name is available 
in https://tools.ietf.org/html/rfc5280#section-4.1.2.4[RFC-5280, Section 
4.1.2.4].
 Values may have spaces, punctuation, and other characters.
 
-It is best practice to verify the actual contents of certificates issued by 
your trusted certificate authority before configuring authorization based on 
the contents.
+=== Configurable Principal Resolution
+
+Using the `PathBasedCertPrincipalResolver`, you can extract specific fields or 
apply filters to resolve the `principal`. For example:
+
+[source,json]
+----
+
+{
+  "authentication": {
+    "class": "solr.CertAuthPlugin",
+    "principalResolver": {
+      "class": "solr.PathBasedCertPrincipalResolver",
+      "params": {
+        "path": "subject.dn"
+      }
+    }
+  }
+}
+
+----
+
+This configuration extracts the entire Subject DN, e.g., CN=John Doe,O=Solr 
Corp,OU=Engineering,C=US.
+In fact, this is equivalent to the default `principalResolver` which is used 
if no `principalResolver` configuration is provided.
+
+You can also extract a specific field from the Subject DN:
+
+[source,json]
+----
+
+{
+  "authentication": {
+    "class": "solr.CertAuthPlugin",
+    "principalResolver": {
+      "class": "solr.PathBasedCertPrincipalResolver",
+      "params": {
+        "path": "subject.dn.CN"
+      }
+    }
+  }
+}
+
+----
+Behavior: This configuration extracts the Common Name (CN) field only, e.g., 
John Doe.
+
+The below configuration extracts the email address from the SAN field, e.g., 
[email protected].
+
+[source,json]
+----
+
+{
+  "authentication": {
+    "class": "solr.CertAuthPlugin",
+    "principalResolver": {
+      "class": "solr.PathBasedCertPrincipalResolver",
+      "params": {
+        "path": "san.email"
+      }
+    }
+  }
+}
+
+----
+
+==== Filtered Extraction
+
+For more control, you can apply filters to extract `principals` that meet 
specific criteria.
+Filters use the `checkType` parameter to define matching rules and optionally 
support patterns for further refinement.
+When filtering matches multiple values, the first matching value is picked for 
the `principal`. The `values` filter represent a list of accepted values.
+
+==== Filtering with checkType
+
+The following example extracts email addresses from the `SAN` field only if 
they end with a specific domain:
+
+
+[source,json]
+----
+
+{
+  "authentication": {
+    "class": "solr.CertAuthPlugin",
+    "principalResolver": {
+      "class": "solr.PathBasedCertPrincipalResolver",
+      "params": {
+        "path": "san.email",
+        "filter": {
+          "checkType": "endsWith",
+          "values": ["@example.com"]
+        }
+      }
+    }
+  }
+}
+
+----
+
+The supported `checkType` options are:
+
+`equals`: Matches values exactly.
+
+`startsWith`: Matches values starting with the specified string.
+
+`endsWith`: Matches values ending with the specified string.
+
+`contains`: Matches values containing the specified string.
+
+`wildcard`: Matches any value.
+
+
+==== Combining Filters with Pattern Matching
+
+Filters can also use `extract` patterns (`after` and `before`) to refine the 
extracted `principal`.
+For example, given a cert with `[email protected]`, the 
following configuration resolves "admin" as the request's `principal`. This is 
done in 3 steps:
+
+- Read `SAN.email` fields.
+- Filter out to accept only emails ending with "@example.com" (pick the first 
match).
+- In the resolved email extract the string between "_" and "@".
+
+[source,json]
+----
+"principalResolver": {
+  "class": "solr.PathBasedCertPrincipalResolver",
+  "params": {
+    "path": "SAN.email",
+    "filter": {
+      "checkType": "endsWith",
+      "values": ["@example.com"]
+    },
+    "extract": {
+                 "after": "_",
+                 "before": "@",
+         }
+  }
+}
+
+----
+
+
+==== Match DNS Names Starting with a Prefix
+
+Behavior: Extracts `DNS` names like `service-api.example.com` or 
`service-db.example.org`:
+
+[source,json]
+----
+
+{
+  "authentication": {
+    "class": "solr.CertAuthPlugin",
+    "principalResolver": {
+      "class": "solr.PathBasedCertPrincipalResolver",
+      "params": {
+        "path": "san.dns",
+        "filter": {
+          "checkType": "startsWith",
+          "values": ["service-"]
+        }
+      }
+    }
+  }
+}
+
+----
+
+==== Match DNS Names Ending with a Specific Domain
+
+Behavior: Extract `DNS` names that end with a specific domain, such as 
`.example.com`:
+
+[source,json]
+----
+
+{
+  "authentication": {
+    "class": "solr.CertAuthPlugin",
+    "principalResolver": {
+      "class": "solr.PathBasedCertPrincipalResolver",
+      "params": {
+        "path": "san.dns",
+        "filter": {
+          "checkType": "endsWith",
+          "values": [".example.com"]
+        }
+      }
+    }
+  }
+}
+
+----
+
+==== Match Multiple Specific DNS Names
+
+Behavior: Extract `DNS` names if they match any value from a predefined list:
+
+[source,json]
+----
+
+{
+  "authentication": {
+    "class": "solr.CertAuthPlugin",
+    "principalResolver": {
+      "class": "solr.PathBasedCertPrincipalResolver",
+      "params": {
+        "path": "san.dns",
+        "filter": {
+          "checkType": "equals",
+          "values": ["api.example.com", "db.example.com"]
+        }
+      }
+    }
+  }
+}
+
+----
+
+
+==== Combine Filtering and Patterns
+
+Extract a portion of a `DNS` name that ends with .`example.com` but only 
return the portion before `.example.com`:
+
+
+[source,json]
+----
+
+{
+  "authentication": {
+    "class": "solr.CertAuthPlugin",
+    "principalResolver": {
+      "class": "solr.PathBasedCertPrincipalResolver",
+      "params": {
+        "path": "san.dns",
+        "filter": {
+          "checkType": "endsWith",
+          "values": [".example.com"]
+        },
+        "extract": {
+          "before": ".example.com"
+        }
+      }
+    }
+  }
+}
+
+----
+`Behavior`: For a DNS name like `service.example.com`, extracts only `service`.
+
+
+==== Filter and Extract with Both Prefix and Suffix
+
+Extract a portion of a DNS name that starts with `service-` and ends with 
`.example.com`:
+
+[source,json]
+----
+
+{
+  "authentication": {
+    "class": "solr.CertAuthPlugin",
+    "principalResolver": {
+      "class": "solr.PathBasedCertPrincipalResolver",
+      "params": {
+        "path": "san.dns",
+        "filter": {
+          "checkType": "startsWith",
+          "values": ["service-"]
+        },
+        "extract": {
+          "after": "service-",
+          "before": ".example.com"
+        }
+      }
+    }
+  }
+}
+
+----
+
+`Behavior`: For `service-api.example.com`, extracts only `api`.
+
+
+==== Summary of supported Fields
+
+==== Subject DN Fields:
+
+The list of supported fields is available in 
https://tools.ietf.org/html/rfc5280#section-4.1.2.4[RFC-5280, Section 4.1.2.4]. 
Below are most common fields:
+
+`subject.dn.CN` (Common Name)
+
+`subject.dn.O` (Organization)
+
+`subject.dn.OU` (Organizational Unit)
+
+`subject.dn.C` (Country)
+
+`subject.dn.ST` (State/Province)
+
+`subject.dn.L` (Locality)
+
+
+==== Issuer DN Fields:
+
+Same fields as Subject DN but prefixed with issuer.dn.
+
+==== SAN Fields:
+
+List of supported SAN fields is compatible with 
https://tools.ietf.org/html/rfc5280#section-4.2.1.6[RFC-5280, Section 4.2.1.6].
+
+`san.email` (Email addresses)
+
+`san.dns` (DNS names)
+
+`san.uri` (URIs)
+
+`san.ipaddress` (IP Addresses)
+
+`san.othername` (Other names)
+
+`san.x400address` (X400 addresses)
+
+`san.directoryname` (Directory names)
+
+`san.edipartyname` (EDI party names)
+
+`san.registeredid` (Registered IDs)
+
+
+=== Custom Principal Resolution
+
+To use a custom `principal` resolver, implement the 
{solr-javadocs}/core/org/apache/solr/security/cert/CertPrincipalResolver.html[`CertPrincipalResolver`]
  interface in your class. The interface requires you to define how to resolve 
a principal from an `X.509` certificate. Here's an example of a basic custom 
implementation:
+
+[source,java]
+----
+package com.example.solr;
+
+import java.security.Principal;
+import java.security.cert.X509Certificate;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import org.apache.solr.security.cert.CertPrincipalResolver;
+import org.apache.solr.security.cert.CertPrincipalResolver;
+
+public class CustomCertPrincipalResolver implements CertPrincipalResolver {
+
+  public CustomCertPrincipalResolver(Map<String, Object> params) {
+    // use the 'params' object for some initialization
+  }
+
+  @Override
+  public Principal resolvePrincipal(X509Certificate certificate) throws 
SSLPeerUnverifiedException {
+    // Custom logic to extract the principal
+    String customPrincipalName = 
certificate.getSubjectX500Principal().getName();
+    // Modify or process the principal name if needed
+    return new BasicUserPrincipal(customPrincipalName);
+  }
+}
+
+----
+
+*Using the Custom Resolver in security.json*
+
+Once the custom resolver class is implemented and available in your Solr 
classpath, reference it in the `security.json` configuration file:
+
+[source,json]
+----
+
+{
+  "authentication": {
+    "class": "solr.CertAuthPlugin",
+    "principalResolver": {
+      "class": "com.example.solr.CustomCertPrincipalResolver",
+      "params": {}
+    }
+  }
+}
+
+----
 
 == Using Certificate Auth with Clients (including SolrJ)
 

Reply via email to