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)