diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationConfig.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationConfig.java
index 5d4eb9b40b7..76a4459e418 100644
--- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationConfig.java
+++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationConfig.java
@@ -24,6 +24,7 @@
import java.util.Arrays;
import java.util.Collections;
import java.util.Set;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.zookeeper.server.auth.znode.groupacl.X509ZNodeGroupAclProvider;
import org.slf4j.Logger;
@@ -83,6 +84,28 @@ public static X509AuthenticationConfig getInstance() {
public static final String SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX =
SSL_X509_CONFIG_PREFIX + "clientCertIdSanExtractMatcherGroupIndex";
public static final String SUBJECT_ALTERNATIVE_NAME_SHORT = "SAN";
+
+ /**
+ * Regex to identify SPIFFE URI SANs (type 6). When set, URI SANs matching this regex are
+ * treated as SPIFFE identities. The extractor accepts both:
+ *
+ *
v2 ({@code /v2/}): principal is the full ILM UID (path-after-{@code /v2/})
+ *
v1 workload ({@code /v1/wl/}): principal is just {@code }
+ * (the {@code wl/} type prefix is stripped)
+ *
+ * User-identity URIs ({@code /v/user/...}) and other non-{v1/wl,v2} paths fall through to
+ * URN/DN extraction regardless of this regex. If not set, SPIFFE extraction is disabled.
+ *
+ *
Recommended: constrain to a specific trust domain, e.g.
+ * {@code ^spiffe://prod\.lipki/v[12]/.*$}. A permissive regex like {@code ^spiffe://.*$} accepts
+ * SPIFFE URIs from any trust domain, relying on the upstream TLS trust manager alone to reject
+ * untrusted issuers.
+ *
+ *
ACL matching downstream is segment-prefix on the extracted UID; see
+ * {@code X509AuthenticationUtil#matchAndExtractSpiffeSAN}.
+ */
+ public static final String SSL_X509_SPIFFE_SAN_MATCH_REGEX =
+ SSL_X509_CONFIG_PREFIX + "spiffe.sanMatchRegex";
private static final String DEFAULT_REGEX = ".*";
private String clientCertIdType;
private int clientCertIdSanMatchType = -1;
@@ -90,6 +113,9 @@ public static X509AuthenticationConfig getInstance() {
private String clientCertIdSanExtractRegex;
private int clientCertIdSanExtractMatcherGroupIndex = -1;
+ private volatile Pattern spiffeSanMatchPattern;
+ private volatile boolean spiffeSanMatchPatternLoaded = false;
+
// ZooKeeper server-side config properties for ZNode group ACL feature
/**
@@ -174,6 +200,7 @@ public static X509AuthenticationConfig getInstance() {
private final Object crossDomainAccessDomainsLock = new Object();
private final Object znodeGroupAclSuperUserIdsLock = new Object();
private final Object allowedClientIdAsAclDomainsLock = new Object();
+ private final Object spiffeSanMatchPatternLock = new Object();
// Setters for X509 properties
@@ -226,6 +253,30 @@ public void setClientCertIdSanExtractMatcherGroupIndex(
}
}
+ public void setSpiffeSanMatchRegex(String spiffeSanMatchRegex) {
+ LOG.debug("{} = {}", SSL_X509_SPIFFE_SAN_MATCH_REGEX, spiffeSanMatchRegex);
+ this.spiffeSanMatchPattern = spiffeSanMatchRegex == null ? null : Pattern.compile(spiffeSanMatchRegex);
+ this.spiffeSanMatchPatternLoaded = true;
+ }
+
+ /**
+ * Compiled SPIFFE SAN match pattern, or null if SPIFFE extraction is not configured.
+ * Loaded lazily from system properties on first access using double-checked locking against
+ * {@code spiffeSanMatchPatternLock}, matching the pattern used by other lazy-loaded fields in
+ * this class (e.g. {@link #getAllowedClientIdAsAclDomains()}).
+ */
+ @SuppressFBWarnings("DC_DOUBLECHECK")
+ public Pattern getSpiffeSanMatchPattern() {
+ if (!spiffeSanMatchPatternLoaded) {
+ synchronized (spiffeSanMatchPatternLock) {
+ if (!spiffeSanMatchPatternLoaded) {
+ setSpiffeSanMatchRegex(System.getProperty(SSL_X509_SPIFFE_SAN_MATCH_REGEX));
+ }
+ }
+ }
+ return spiffeSanMatchPattern;
+ }
+
// Setters for X509 Znode Group Acl properties
public void setX509ClientIdAsAclEnabled(String enabled) {
@@ -482,4 +533,5 @@ public static void reset() {
instance = null;
}
}
+
}
diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationUtil.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationUtil.java
index 88ae5a464b9..63c31f91b78 100644
--- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationUtil.java
+++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationUtil.java
@@ -18,11 +18,13 @@
package org.apache.zookeeper.server.auth;
+import java.net.URI;
import java.security.cert.CertificateException;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.List;
+import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -48,6 +50,24 @@ public class X509AuthenticationUtil extends X509Util {
public static final String SUPERUSER_AUTH_SCHEME = "super";
public static final String X509_SCHEME = "x509";
+ // Matches LISPIFFE user-identity paths of the form "/v/user" or "/v/user/".
+ // User-identity SPIFFE certs (issued to humans, not workloads) must NOT be promoted to a
+ // service principal, otherwise a user credential would be granted service-level ACL access.
+ // See LISPIFFE-ID spec: https://github.com/linkedin-multiproduct/gopki/blob/master/LISPIFFE-ID.md
+ private static final Pattern SPIFFE_USER_IDENTITY_PATH_PATTERN =
+ Pattern.compile("^/v\\d+/user(/.*)?$");
+
+ // Matches LISPIFFE v2 paths and captures the ILM UID (the path after "/v2/").
+ // The canonical ILM v2 principal is the full path-after-v2 (e.g. "application/foo-mp/bar-app");
+ // ACL matching downstream is segment-prefix on this UID.
+ private static final Pattern SPIFFE_V2_PATH_PATTERN = Pattern.compile("^/v2/(.+)$");
+
+ // Matches LISPIFFE v1 workload paths (only "/v1/wl/..."; not "/v1/wf/..." workflow) and
+ // captures the app-name. Per LISPIFFE-ID spec, the v1 workload unique-identity is
+ // "wl/"; we strip the "wl/" type prefix and return just the app-name as the
+ // principal, matching how legacy authZ systems handled v1 identities.
+ private static final Pattern SPIFFE_V1_WL_PATH_PATTERN = Pattern.compile("^/v1/wl/(.+)$");
+
@Override
protected String getConfigPrefix() {
return X509AuthenticationConfig.SSL_X509_CONFIG_PREFIX;
@@ -134,16 +154,136 @@ public static String getClientId(X509Certificate clientCert) {
String clientCertIdType = X509AuthenticationConfig.getInstance().getClientCertIdType();
if (clientCertIdType != null && clientCertIdType
.equalsIgnoreCase(X509AuthenticationConfig.SUBJECT_ALTERNATIVE_NAME_SHORT)) {
+ try {
+ Optional spiffeId = X509AuthenticationUtil.matchAndExtractSpiffeSAN(clientCert);
+ if (spiffeId.isPresent()) {
+ LOG.debug("Extracted SPIFFE identity: {}", spiffeId.get());
+ return spiffeId.get();
+ }
+ } catch (Exception e) {
+ LOG.warn("Failed to extract SPIFFE identity from SAN. Falling through to URN-based extraction.", e);
+ }
try {
return X509AuthenticationUtil.matchAndExtractSAN(clientCert);
} catch (Exception ce) {
LOG.warn("Failed to match and extract a client ID from SAN. Using Subject DN instead.", ce);
}
}
- // return Subject DN by default
return clientCert.getSubjectX500Principal().getName();
}
+ /**
+ * Attempt to extract a client identity from a LISPIFFE URI SAN. Supported forms:
+ *
+ *
v2 ({@code spiffe://
/v2/}): principal is the full path-after-{@code /v2/}
+ * (the ILM UID), e.g. {@code spiffe://prod.lipki/v2/application/foo-mp/bar-app} →
+ * {@code application/foo-mp/bar-app}. ACL matching downstream is segment-prefix on the UID.
+ *
v1 workload ({@code spiffe://
/v1/wl/}): principal is just the
+ * {@code } (the "wl/" type prefix is stripped, matching how legacy authZ
+ * handled v1 identities).
+ *
+ *
+ *
Returns {@link Optional#empty()} when SPIFFE extraction is disabled, no URI SAN matches the
+ * configured regex, the matched URI is a user identity ({@code /v/user/...}, which must
+ * never be promoted to a service principal), or the matched URI is a non-{v1/wl, v2} path
+ * (e.g. v1 workflow {@code /v1/wf/...}). Caller falls through to URN/DN extraction.
+ *
+ * @throws IllegalArgumentException if multiple URI SANs match the SPIFFE regex
+ */
+ private static Optional matchAndExtractSpiffeSAN(X509Certificate clientCert)
+ throws CertificateParsingException {
+ Pattern matchPattern = X509AuthenticationConfig.getInstance().getSpiffeSanMatchPattern();
+ if (matchPattern == null) {
+ return Optional.empty();
+ }
+
+ String spiffeUri = findSingleMatchingSan(clientCert, 6, matchPattern, "SPIFFE");
+ if (spiffeUri == null) {
+ return Optional.empty();
+ }
+
+ String path;
+ try {
+ // getRawPath() returns the literal (un-percent-decoded) path so the principal we accept is
+ // exactly what the CA validated in the SAN. getPath() would decode %2F → /, allowing a
+ // single-segment SAN like /v2/foo%2Fbar to be promoted to a multi-segment principal that
+ // could collide with an unrelated registered identity. Reject any path containing % to
+ // also block encoded "user" bypass (e.g. /v2/%75ser/alice).
+ path = URI.create(spiffeUri).getRawPath();
+ } catch (IllegalArgumentException e) {
+ LOG.debug("Malformed SPIFFE URI '{}'; falling through to URN/DN extraction.", spiffeUri);
+ return Optional.empty();
+ }
+ if (path == null) {
+ return Optional.empty();
+ }
+ if (path.indexOf('%') >= 0) {
+ LOG.debug("Rejecting SPIFFE URI with percent-encoded path '{}'; falling through.", spiffeUri);
+ return Optional.empty();
+ }
+ if (SPIFFE_USER_IDENTITY_PATH_PATTERN.matcher(path).matches()) {
+ LOG.debug("Rejecting SPIFFE user identity '{}' for service-principal extraction.", spiffeUri);
+ return Optional.empty();
+ }
+ Matcher v2Matcher = SPIFFE_V2_PATH_PATTERN.matcher(path);
+ if (v2Matcher.matches()) {
+ return Optional.of(v2Matcher.group(1));
+ }
+ Matcher v1WlMatcher = SPIFFE_V1_WL_PATH_PATTERN.matcher(path);
+ if (v1WlMatcher.matches()) {
+ return Optional.of(v1WlMatcher.group(1));
+ }
+ LOG.debug("SPIFFE URI '{}' is not a v1/wl or v2 identity; falling through to URN/DN extraction.",
+ spiffeUri);
+ return Optional.empty();
+ }
+
+ /**
+ * Returns the single SAN value of the given type whose value matches the regex, or null if
+ * there are zero matches. Throws if there are multiple matches (callers always want exactly one).
+ */
+ private static String findSingleMatchingSan(X509Certificate cert, int sanType, Pattern pattern,
+ String matchKind) throws CertificateParsingException {
+ String found = null;
+ Collection> sans = cert.getSubjectAlternativeNames();
+ if (sans == null) {
+ return null;
+ }
+ for (List> san : sans) {
+ if (!Integer.valueOf(sanType).equals(san.get(0))) {
+ continue;
+ }
+ String value = san.get(1).toString();
+ if (!pattern.matcher(value).find()) {
+ continue;
+ }
+ if (found != null) {
+ String errStr = "Expected exactly 1 " + matchKind + " SAN but found more than 1. "
+ + "Please fix the match regex so exactly one match is found.";
+ LOG.error(errStr);
+ throw new IllegalArgumentException(errStr);
+ }
+ found = value;
+ }
+ return found;
+ }
+
+ /**
+ * Applies an extract regex to a SAN value and returns the captured group.
+ *
+ * @throws IllegalArgumentException if the regex does not match.
+ */
+ private static String applyExtractRegex(Pattern extractPattern, String value, int groupIndex) {
+ Matcher matcher = extractPattern.matcher(value);
+ if (!matcher.find()) {
+ String errStr = "Failed to extract identity from '" + value
+ + "' using regex '" + extractPattern.pattern() + "'";
+ LOG.error(errStr);
+ throw new IllegalArgumentException(errStr);
+ }
+ return matcher.group(groupIndex);
+ }
+
/**
* Extract the authenticated client Id from the specified server connection object.
* @param cnxn Server connection object that contains the certificate.
@@ -204,18 +344,8 @@ private static String matchAndExtractSAN(X509Certificate clientCert)
throw new IllegalArgumentException(errStr);
}
- // Extract a substring from the found match using extractRegex
- Pattern extractPattern = Pattern.compile(extractRegex);
- Matcher matcher = extractPattern.matcher(matched.iterator().next().get(1).toString());
- if (matcher.find()) {
- // If extractMatcherGroupIndex is not given, return the 1st index by default
- String result = matcher.group(extractMatcherGroupIndex);
- LOG.debug("Returning extracted client ID: {} using Matcher group index: {}", result, extractMatcherGroupIndex);
- return result;
- }
- String errStr = "Failed to find an extract substring to determine client ID. Please review the extract regex.";
- LOG.error(errStr);
- throw new IllegalArgumentException(errStr);
+ return applyExtractRegex(Pattern.compile(extractRegex),
+ matched.iterator().next().get(1).toString(), extractMatcherGroupIndex);
}
/**
diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProvider.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProvider.java
index 34869b83c4f..688a99479e1 100644
--- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProvider.java
+++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProvider.java
@@ -19,7 +19,6 @@
package org.apache.zookeeper.server.auth.znode.groupacl;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
-import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -164,11 +163,14 @@ private ClientUriDomainMappingHelper getUriDomainMappingHelper(ZooKeeperServer z
// Set up AuthInfo updater to refresh connection AuthInfo on any client domain changes.
// TODO Making the anonymous class to a separate updater implementation class if any other Acl provider shares
// the same logic.
- helper.setDomainAuthUpdater((cnxn, clientUriToDomainNames) -> {
+ // Route through helper.getDomains(clientId) so SPIFFE multi-segment principals resolve
+ // via the segment-prefix walk-up (operator can register an MP-level leaf to grant all
+ // apps under that MP; see ZkClientUriDomainMappingHelper class javadoc). The map passed
+ // into the lambda is ignored — kept in the interface signature for backward compat.
+ helper.setDomainAuthUpdater((cnxn, ignoredMap) -> {
try {
String clientId = X509AuthenticationUtil.getClientId(cnxn, trustManager);
- assignAuthInfo(cnxn, clientId,
- clientUriToDomainNames.getOrDefault(clientId, Collections.emptySet()));
+ assignAuthInfo(cnxn, clientId, helper.getDomains(clientId));
} catch (UnsupportedOperationException unsupportedEx) {
LOG.info("Cannot update AuthInfo for session 0x{} since the operation is not supported.",
Long.toHexString(cnxn.getSessionId()));
diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/znode/groupacl/ZkClientUriDomainMappingHelper.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/znode/groupacl/ZkClientUriDomainMappingHelper.java
index 9061a76cbce..c282097bb7c 100644
--- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/znode/groupacl/ZkClientUriDomainMappingHelper.java
+++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/znode/groupacl/ZkClientUriDomainMappingHelper.java
@@ -18,6 +18,7 @@
package org.apache.zookeeper.server.auth.znode.groupacl;
+import com.google.common.annotations.VisibleForTesting;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.util.Collections;
import java.util.HashMap;
@@ -44,16 +45,28 @@
* be cached inside this helper object. This helper object watches the clientUri-domain ZNodes and
* updates the internal Map accordingly.
*
- * The following illustrates the ZNode hierarchy:
- * . (root)
- * └── /zookeeper/uri-domain-map (mapping root path)
- * ├── bar (application domain)
- * │ ├── bar0 (client URI)
- * │ └── bar1 (client URI)
- * └── foo (application domain)
- * ├── foo1 (client URI)
- * ├── foo2 (client URI)
- * └── foo3 (client URI)
+ * Each leaf znode below a domain is registered as a client URI; the URI is the
+ * {@code /}-joined path of znode names from the domain down. Since znode names themselves
+ * cannot contain {@code /}, multi-segment SPIFFE ILM UIDs
+ * ({@code application//[/]}) are expressed as a path of nested znodes. Intermediate
+ * znodes are structural only — they are not registered as keys, which prevents a stray
+ * single-segment znode (e.g. {@code workload}) from matching every SPIFFE workload identity.
+ *
+ *
+ *
+ * To grant an MP-level prefix instead, register the MP node as a leaf (i.e. omit app-level
+ * children); the segment-prefix walk-up in {@link #getDomains(String)} then matches any UID
+ * with that prefix.
*
* Note: It is not expected that there would be too many distinct client URIs so as to overwhelm
* heap usage.
@@ -65,7 +78,10 @@ public class ZkClientUriDomainMappingHelper implements ClientUriDomainMappingHel
private final ZooKeeperServer zks;
private final String rootPath;
- private Map> clientUriToDomainNames = Collections.emptyMap();
+ // volatile to publish the reassignment in parseZNodeMapping (watcher thread) to readers in
+ // getDomains (request-handler threads); see allowedClientIdAsAclDomains in X509AuthenticationConfig
+ // for the same pattern.
+ private volatile Map> clientUriToDomainNames = Collections.emptyMap();
private ConnectionAuthInfoUpdater updater = null;
public ZkClientUriDomainMappingHelper(ZooKeeperServer zks) {
@@ -116,38 +132,101 @@ private void addWatches() {
}
/**
- * Read ZNodes under the root path and populates clientUriToDomainNames.
- * Note: this is not thread-safe nor atomic; however, we do not need such strong guarantee with
- * this read operation.
- *
- * Also, note that this is a purely in-memory operation, so re-parsing the entire tree should not
- * be a big overhead considering how infrequently the mapping is supposed to be changed.
+ * Re-read the entire mapping subtree and swap in a new {@code clientUriToDomainNames}. See
+ * class Javadoc for the registration rule. Runs on bootstrap and on watcher fire (infrequent),
+ * purely in-memory. Not thread-safe with itself; the volatile reassignment publishes a
+ * consistent map to readers.
*/
private void parseZNodeMapping() {
Map> newClientUriToDomainNames = new HashMap<>();
try {
List domainNames = zks.getZKDatabase().getChildren(rootPath, null, null);
- domainNames.forEach(domainName -> {
- try {
- List clientUris =
- zks.getZKDatabase().getChildren(rootPath + "/" + domainName, null, null);
- clientUris.forEach(clientUri -> {
- LOG.info("Registering client URI domain mapping: {} --> {}", clientUri, domainName);
- newClientUriToDomainNames.computeIfAbsent(clientUri, k -> new HashSet<>()).add(domainName);
- });
- } catch (KeeperException.NoNodeException e) {
- LOG.warn("No clientUri ZNodes found under domain: {}", domainName);
- }
- });
+ for (String domainName : domainNames) {
+ collectClientUris(rootPath + "/" + domainName, "", domainName, newClientUriToDomainNames);
+ }
} catch (KeeperException.NoNodeException e) {
LOG.warn("No application domain ZNodes found in root path: {}", rootPath);
}
clientUriToDomainNames = newClientUriToDomainNames;
}
+ private void collectClientUris(String currentPath, String accumulatedUri, String domainName,
+ Map> map) {
+ List children;
+ try {
+ children = zks.getZKDatabase().getChildren(currentPath, null, null);
+ } catch (KeeperException.NoNodeException e) {
+ return;
+ }
+ if (children.isEmpty()) {
+ // Only leaf znodes are registered as client URIs. Intermediate znodes are structural —
+ // registering them would grant the domain to any client whose UID happens to share that
+ // prefix segment (e.g. registering a 1-segment "workload" key would match every SPIFFE
+ // workload identity). Operators express grants by creating leaves at the intended depth.
+ if (!accumulatedUri.isEmpty()) {
+ LOG.info("Registering client URI domain mapping: {} --> {}", accumulatedUri, domainName);
+ map.computeIfAbsent(accumulatedUri, k -> new HashSet<>()).add(domainName);
+ }
+ return;
+ }
+ for (String child : children) {
+ String childUri = accumulatedUri.isEmpty() ? child : accumulatedUri + "/" + child;
+ collectClientUris(currentPath + "/" + child, childUri, domainName, map);
+ }
+ }
+
+ @VisibleForTesting
+ void setClientUriToDomainNames(Map> mapping) {
+ this.clientUriToDomainNames = mapping;
+ }
+
+ /**
+ * Resolve the set of application domains for a given client URI.
+ *
+ * Lookup proceeds in two stages:
+ *
+ *
Exact match: if the URI is registered verbatim in the mapping, its domain set
+ * is returned as-is. URN-style identifiers (e.g. {@code urn:li:servicePrincipal(...)})
+ * contain no {@code '/'} and therefore always resolve here or not at all.
+ *
Segment-prefix walk-up: if no exact match exists and the URI contains at least
+ * one {@code '/'}, split on {@code '/'} and probe each strictly-shorter left prefix
+ * (anchored at the start, aligned on {@code '/'} boundaries). Domains from every prefix
+ * that is present in the map are unioned into the result. This supports SPIFFE-style ILM
+ * UIDs of the form {@code application//[/]}, where registering
+ * {@code application/} covers all of its apps and tags without wildcards.
+ *
+ * A {@code null} URI yields the empty set.
+ */
@Override
public Set getDomains(String clientUri) {
- return clientUriToDomainNames.getOrDefault(clientUri, Collections.emptySet());
+ if (clientUri == null) {
+ return Collections.emptySet();
+ }
+ // Snapshot the mapping reference once. parseZNodeMapping reassigns the field on watcher
+ // fire; without this snapshot, the exact-match and the prefix walk-up below could read
+ // different map references mid-call and return an inconsistent answer.
+ Map> map = clientUriToDomainNames;
+ Set exact = map.get(clientUri);
+ if (exact != null) {
+ return exact;
+ }
+ if (clientUri.indexOf('/') < 0) {
+ return Collections.emptySet();
+ }
+ String[] segments = clientUri.split("/");
+ Set result = new HashSet<>();
+ StringBuilder prefix = new StringBuilder(clientUri.length());
+ for (int n = 1; n < segments.length; n++) {
+ if (n > 1) {
+ prefix.append('/');
+ }
+ prefix.append(segments[n - 1]);
+ Set match = map.get(prefix.toString());
+ if (match != null) {
+ result.addAll(match);
+ }
+ }
+ return result.isEmpty() ? Collections.emptySet() : result;
}
@Override
diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/common/SpiffeAuthTestUtil.java b/zookeeper-server/src/test/java/org/apache/zookeeper/common/SpiffeAuthTestUtil.java
new file mode 100644
index 00000000000..4fcb9fe2945
--- /dev/null
+++ b/zookeeper-server/src/test/java/org/apache/zookeeper/common/SpiffeAuthTestUtil.java
@@ -0,0 +1,128 @@
+/*
+ * 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.zookeeper.common;
+
+import java.net.Socket;
+import java.security.KeyPair;
+import java.security.Principal;
+import java.security.PrivateKey;
+import java.security.Security;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import javax.net.ssl.X509KeyManager;
+import javax.net.ssl.X509TrustManager;
+import org.apache.zookeeper.server.auth.X509AuthenticationConfig;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.GeneralNames;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+
+/**
+ * Test fixtures for SPIFFE-based authentication: BouncyCastle bootstrap, system-property
+ * setup/teardown for the {@code spiffe.sanMatchRegex} config, real X509 client cert builder
+ * with URI SANs, and stub TLS managers. Shared across SPIFFE auth tests.
+ */
+public final class SpiffeAuthTestUtil {
+
+ public static final long ONE_DAY_MILLIS = 24L * 60 * 60 * 1000;
+ /** Match regex that accepts SPIFFE v1 and v2 URIs (any trust domain). */
+ public static final String SPIFFE_MATCH_REGEX = "^spiffe://.*/v[12]/.*$";
+
+ private SpiffeAuthTestUtil() {
+ }
+
+ public static void registerBouncyCastle() {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ }
+
+ /** Configures SAN+SPIFFE-v2 extraction in the X509AuthenticationConfig singleton. */
+ public static void setSpiffeSystemProperties() {
+ System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN");
+ System.setProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX, SPIFFE_MATCH_REGEX);
+ X509AuthenticationConfig.reset();
+ }
+
+ public static void clearSpiffeSystemProperties() {
+ System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE);
+ System.clearProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX);
+ X509AuthenticationConfig.reset();
+ }
+
+ /**
+ * Builds a real BouncyCastle-signed X509 client certificate with the given URI SANs. Requires
+ * {@link #registerBouncyCastle()} to have been called once per JVM.
+ */
+ public static X509Certificate buildClientCertWithUriSans(String... uriSans) throws Exception {
+ KeyPair caKey = X509TestHelpers.generateRSAKeyPair();
+ X509Certificate caCert = X509TestHelpers.newSelfSignedCACert(
+ new X500Name("CN=Test CA"), caKey, ONE_DAY_MILLIS);
+ KeyPair clientKey = X509TestHelpers.generateRSAKeyPair();
+ GeneralName[] names = new GeneralName[uriSans.length];
+ for (int i = 0; i < uriSans.length; i++) {
+ names[i] = new GeneralName(GeneralName.uniformResourceIdentifier, uriSans[i]);
+ }
+ return X509TestHelpers.newCertWithSans(caCert, caKey,
+ new X500Name("CN=test-client"), clientKey.getPublic(),
+ new GeneralNames(names), ONE_DAY_MILLIS);
+ }
+
+ /** Trust manager that accepts any cert; for auth-flow tests that don't exercise trust validation. */
+ public static final class AcceptAllTrustManager implements X509TrustManager {
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ }
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ }
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+ }
+
+ /** Key manager that returns null for everything; for tests that don't serve outbound TLS. */
+ public static final class NoopKeyManager implements X509KeyManager {
+ @Override
+ public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) {
+ return null;
+ }
+ @Override
+ public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
+ return null;
+ }
+ @Override
+ public X509Certificate[] getCertificateChain(String alias) {
+ return null;
+ }
+ @Override
+ public String[] getClientAliases(String keyType, Principal[] issuers) {
+ return null;
+ }
+ @Override
+ public PrivateKey getPrivateKey(String alias) {
+ return null;
+ }
+ @Override
+ public String[] getServerAliases(String keyType, Principal[] issuers) {
+ return null;
+ }
+ }
+}
diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/common/X509TestHelpers.java b/zookeeper-server/src/test/java/org/apache/zookeeper/common/X509TestHelpers.java
index b9f2f6db946..68e2ee372a7 100644
--- a/zookeeper-server/src/test/java/org/apache/zookeeper/common/X509TestHelpers.java
+++ b/zookeeper-server/src/test/java/org/apache/zookeeper/common/X509TestHelpers.java
@@ -130,19 +130,37 @@ now, new Date(now.getTime()
*/
public static X509Certificate newCert(
X509Certificate caCert, KeyPair caKeyPair, X500Name certSubject, PublicKey certPublicKey, long expirationMillis) throws IOException, OperatorCreationException, GeneralSecurityException {
+ return newCertSignedBy(caCert, caKeyPair, certSubject, certPublicKey,
+ getLocalhostSubjectAltNames(), expirationMillis);
+ }
+
+ /**
+ * Variant of {@link #newCert} that installs the supplied SANs instead of the default
+ * localhost SANs. Used by tests that need certs with specific URI SANs (e.g., SPIFFE URIs).
+ */
+ public static X509Certificate newCertWithSans(
+ X509Certificate caCert, KeyPair caKeyPair, X500Name certSubject, PublicKey certPublicKey,
+ GeneralNames sans, long expirationMillis)
+ throws IOException, OperatorCreationException, GeneralSecurityException {
+ return newCertSignedBy(caCert, caKeyPair, certSubject, certPublicKey, sans, expirationMillis);
+ }
+
+ private static X509Certificate newCertSignedBy(
+ X509Certificate caCert, KeyPair caKeyPair, X500Name certSubject, PublicKey certPublicKey,
+ GeneralNames sans, long expirationMillis)
+ throws IOException, OperatorCreationException, GeneralSecurityException {
if (!caKeyPair.getPublic().equals(caCert.getPublicKey())) {
throw new IllegalArgumentException("CA private key does not match the public key in the CA cert");
}
Date now = new Date();
- X509v3CertificateBuilder builder = initCertBuilder(new X500Name(caCert.getIssuerDN().getName()), now, new Date(
- now.getTime()
- + expirationMillis), certSubject, certPublicKey);
- builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); // not a CA
- builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature
- | KeyUsage.keyEncipherment));
- builder.addExtension(Extension.extendedKeyUsage, true, new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth}));
-
- builder.addExtension(Extension.subjectAlternativeName, false, getLocalhostSubjectAltNames());
+ X509v3CertificateBuilder builder = initCertBuilder(new X500Name(caCert.getIssuerDN().getName()),
+ now, new Date(now.getTime() + expirationMillis), certSubject, certPublicKey);
+ builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
+ builder.addExtension(Extension.keyUsage, true,
+ new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment));
+ builder.addExtension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth}));
+ builder.addExtension(Extension.subjectAlternativeName, false, sans);
return buildAndSignCertificate(caKeyPair.getPrivate(), builder);
}
diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/ZkClientUriDomainMappingHelperTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/ZkClientUriDomainMappingHelperTest.java
index 5e8c8996a6c..bc3997dae2a 100644
--- a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/ZkClientUriDomainMappingHelperTest.java
+++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/ZkClientUriDomainMappingHelperTest.java
@@ -19,9 +19,13 @@
package org.apache.zookeeper.server.auth.znode.groupacl;
import java.io.IOException;
+import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashMap;
import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.DummyWatcher;
import org.apache.zookeeper.KeeperException;
@@ -29,14 +33,18 @@
import org.apache.zookeeper.ZKTestCase;
import org.apache.zookeeper.ZooDefs;
import org.apache.zookeeper.ZooKeeper;
+import org.apache.zookeeper.common.SpiffeAuthTestUtil;
+import org.apache.zookeeper.server.MockServerCnxn;
import org.apache.zookeeper.server.ServerCnxn;
import org.apache.zookeeper.server.ServerCnxnFactory;
import org.apache.zookeeper.server.ZooKeeperServer;
+import org.apache.zookeeper.server.auth.ServerAuthenticationProvider;
import org.apache.zookeeper.server.watch.WatchesReport;
import org.apache.zookeeper.test.ClientBase;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
+import org.junit.BeforeClass;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
@@ -58,13 +66,31 @@ public class ZkClientUriDomainMappingHelperTest extends ZKTestCase {
CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/foo",
CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/foo/foo1",
CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/foo/foo2",
- CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/foo/bar1"
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/foo/bar1",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-apps",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-apps/workload",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-apps/workload/helix-core",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-apps/workload/helix-core/helix-controller",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-apps/workload/helix-core/helix-rest",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp/workload",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp/workload/different-mp",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-legacy",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-legacy/urn:li:servicePrincipal(legacy;ei4;i001)",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp-grant",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp-grant/application",
+ CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp-grant/application/helix-core"
};
private ZooKeeperServer zookeeperServer;
private ZooKeeper zookeeperClientConnection;
private ServerCnxnFactory serverCnxnFactory;
+ @BeforeClass
+ public static void registerBouncyCastle() {
+ SpiffeAuthTestUtil.registerBouncyCastle();
+ }
+
@Before
public void setUp() throws IOException, InterruptedException, KeeperException {
LOG.info("Starting Zk...");
@@ -157,6 +183,156 @@ public void testA_ZkClientUriDomainMappingHelper() throws KeeperException, Inter
Assert.assertEquals(new HashSet<>(Arrays.asList("bar", "foo")), helper.getDomains("bar1"));
}
+ /**
+ * Verifies the helper recursively walks multi-level znode subtrees. SPIFFE ILM UIDs include
+ * {@code /} separators (which can't appear in znode names), so they're encoded as a path of
+ * nested znodes; only leaves are registered as keys. The mapping tree:
+ *