From ca0a5721b962eec59b85099f5f0ce2773aaead67 Mon Sep 17 00:00:00 2001 From: Sanju98 Date: Wed, 13 May 2026 22:34:52 +0530 Subject: [PATCH 01/16] Add SPIFFE v2 URI-SAN based principal extraction (DEPEND-89172) Adds server-side support for SPIFFE-issued client certificates in X509 auth. The principal is the ILM UID (path-after-/v2/), aligned with the LinkedIn ILM v2 design: trust-domain stripped, segment-prefix matching for ACL lookup. Key changes: - X509AuthenticationUtil.matchAndExtractSpiffeSAN extracts the ILM UID from v2 SPIFFE URIs. v1 SPIFFE URIs and user-identity URIs (/v/user/...) fall through to existing URN / Subject-DN extraction. - X509AuthenticationConfig adds spiffe.sanMatchRegex as the operator- controlled trust-domain gate. - ZkClientUriDomainMappingHelper recursively walks the znode subtree below each domain. Only leaf znodes are registered as keys (path joined by '/'), letting multi-segment SPIFFE UIDs be expressed as nested znodes (whose names can't contain '/'). getDomains does exact-match then segment-prefix walk-up. clientUriToDomainNames is volatile with in-method snapshot. - Defense-in-depth: SPIFFE path extraction uses URI.getRawPath() and rejects any path containing '%' to prevent URL-decoding bypass of identity checks. Tests: 29/29 pass (X509AuthTest 13, X509SpiffeAuthIntegrationTest 6, ZkClientUriDomainMappingHelperTest 10; includes end-to-end SPIFFE-cert through X509ZNodeGroupAclProvider with real znode mapping). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/auth/X509AuthenticationConfig.java | 40 ++++ .../server/auth/X509AuthenticationUtil.java | 142 ++++++++++- .../ZkClientUriDomainMappingHelper.java | 139 ++++++++--- .../zookeeper/common/SpiffeAuthTestUtil.java | 128 ++++++++++ .../zookeeper/common/X509TestHelpers.java | 36 ++- .../ZkClientUriDomainMappingHelperTest.java | 200 +++++++++++++++- .../apache/zookeeper/test/X509AuthTest.java | 225 +++++++++++++++++- .../test/X509SpiffeAuthIntegrationTest.java | 139 +++++++++++ 8 files changed, 990 insertions(+), 59 deletions(-) create mode 100644 zookeeper-server/src/test/java/org/apache/zookeeper/common/SpiffeAuthTestUtil.java create mode 100644 zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java 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..ff1b80af44d 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,24 @@ 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 (type 6) matching this regex + * are treated as SPIFFE identities; the principal is the path after {@code /v2/} (the ILM UID). + * v1 SPIFFE URIs and user-identity URIs ({@code /v/user/...}) are structurally rejected and + * fall through to URN/DN extraction regardless of this regex. + * If not set, SPIFFE extraction is disabled. + * + *

Recommended: constrain to a specific trust domain and require v2, e.g. + * {@code ^spiffe://prod\.lipki/v2/.*$}. 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; non-v2 URIs accepted by such a regex will still fall through. + * + *

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 +109,9 @@ public static X509AuthenticationConfig getInstance() { private String clientCertIdSanExtractRegex; private int clientCertIdSanExtractMatcherGroupIndex = -1; + private Pattern spiffeSanMatchPattern; + private boolean spiffeSanMatchPatternLoaded = false; + // ZooKeeper server-side config properties for ZNode group ACL feature /** @@ -226,6 +248,23 @@ 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. + */ + public Pattern getSpiffeSanMatchPattern() { + 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 +521,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..0eba3c5d315 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,19 @@ 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 workload 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. v1 SPIFFE URIs are deliberately not + // matched here — they are a deprecated design with app-name collision across MPs. + private static final Pattern SPIFFE_V2_PATH_PATTERN = Pattern.compile("^/v2/(.+)$"); + @Override protected String getConfigPrefix() { return X509AuthenticationConfig.SSL_X509_CONFIG_PREFIX; @@ -134,16 +149,127 @@ 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 v2 URI SAN. Returns the ILM UID — the + * path segment after {@code /v2/} — e.g. {@code spiffe://prod.lipki/v2/application/foo-mp/bar-app} + * yields {@code application/foo-mp/bar-app}. ACL matching downstream is segment-prefix on this + * UID. + * + *

Returns {@link Optional#empty()} when SPIFFE extraction is disabled, no URI SAN matches the + * configured regex, the matched URI is a v1 SPIFFE identity (deprecated; app-name collides + * across MPs), or the matched URI is a user identity ({@code /v/user/...}, which must never + * be promoted to a service principal). 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()) { + LOG.debug("SPIFFE URI '{}' is not a v2 identity; falling through to URN/DN extraction.", + spiffeUri); + return Optional.empty(); + } + return Optional.of(v2Matcher.group(1)); + } + + /** + * 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 +330,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/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. + * + *

Example tree: + *

+ * /zookeeper/uri-domain-map
+ * ├── bar
+ * │   └── urn:li:servicePrincipal(bar;ei4;i001)            → "urn:li:servicePrincipal(bar;ei4;i001)" → bar
+ * └── helix
+ *     └── workload
+ *         └── helix-core
+ *             ├── helix-controller                          → "workload/helix-core/helix-controller" → helix
+ *             └── helix-rest                                → "workload/helix-core/helix-rest"       → helix
+ * 
+ * + * 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: + *
    + *
  1. 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.
  2. + *
  3. 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.
  4. + *
+ * 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..a41f178bf99 --- /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 only v2 SPIFFE URIs (any trust domain). */ + public static final String SPIFFE_V2_MATCH_REGEX = "^spiffe://.*/v2/.*$"; + + 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_V2_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..4850a37760a 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,28 @@ 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)" }; 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 +180,108 @@ 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: + *
+   * helix-apps/workload/helix-core/helix-controller        → "workload/helix-core/helix-controller" → helix-apps
+   * helix-apps/workload/helix-core/helix-rest              → "workload/helix-core/helix-rest"       → helix-apps
+   * helix-mp/workload/different-mp                         → "workload/different-mp"                → helix-mp
+   * helix-legacy/urn:li:servicePrincipal(legacy;ei4;i001)  → "urn:li:..."                           → helix-legacy
+   * 
+ */ + @Test + public void testA2_RecursiveZNodeWalkRegistersMultiLevelClientUris() + throws KeeperException, InterruptedException { + String[] paths = { + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH, + 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)" + }; + for (String path : paths) { + zookeeperClientConnection.create(path, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + } + + ClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + + // App-level leaf: exact match + Assert.assertEquals(Collections.singleton("helix-apps"), + helper.getDomains("workload/helix-core/helix-controller")); + + // App-level leaf: walk-up from a deeper UID (e.g. app + tag) + Assert.assertEquals(Collections.singleton("helix-apps"), + helper.getDomains("workload/helix-core/helix-rest/ltx1-tag")); + + // MP-level leaf: walk-up grants the domain to any app under that MP + Assert.assertEquals(Collections.singleton("helix-mp"), + helper.getDomains("workload/different-mp/any-app/any-tag")); + + // Legacy URN entry resolves via exact match + Assert.assertEquals(Collections.singleton("helix-legacy"), + helper.getDomains("urn:li:servicePrincipal(legacy;ei4;i001)")); + + // No MP-level grant for helix-core, so an unregistered sibling app does NOT match + Assert.assertEquals(Collections.emptySet(), + helper.getDomains("workload/helix-core/unknown-app")); + + // Intermediate znode ("workload") is structural only — not registered as a 1-segment key + Assert.assertEquals(Collections.emptySet(), + helper.getDomains("workload/unrelated-mp/unrelated-app")); + } + + /** + * End-to-end: a real SPIFFE v2 client certificate flowing through + * {@link X509ZNodeGroupAclProvider#handleAuthentication} resolves to the correct + * {@code (x509, )} authInfo entry via the recursive znode mapping. + */ + @Test + public void testA3_SpiffeCertResolvesThroughZNodeMappingToDomainAuthInfo() throws Exception { + String[] paths = { + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH, + 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" + }; + for (String path : paths) { + zookeeperClientConnection.create(path, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + } + + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + try { + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v2/workload/helix-core/helix-controller"); + + X509ZNodeGroupAclProvider provider = new X509ZNodeGroupAclProvider( + new SpiffeAuthTestUtil.AcceptAllTrustManager(), new SpiffeAuthTestUtil.NoopKeyManager()); + + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{cert}; + + KeeperException.Code result = provider.handleAuthentication( + new ServerAuthenticationProvider.ServerObjs(zookeeperServer, cnxn), null); + + Assert.assertEquals(KeeperException.Code.OK, result); + + boolean foundDomain = cnxn.getAuthInfo().stream() + .anyMatch(id -> "x509".equals(id.getScheme()) && "helix-apps".equals(id.getId())); + Assert.assertTrue( + "Expected (x509, helix-apps) in authInfo; actual: " + cnxn.getAuthInfo(), + foundDomain); + } finally { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + @Test /** * Make sure the watcher installed while instantiate ZkClientUriDomainMappingHelper does not break @@ -167,4 +292,77 @@ public void testB_GetWatches() { WatchesReport report = zookeeperServer.getZKDatabase().getDataTree().getWatches(); Assert.assertEquals(1, report.getPaths(0).size()); } + + @Test + public void testC_GetDomainsExactMatch() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + Map> mapping = new HashMap<>(); + mapping.put("workload/foo-mp/bar-app", + new HashSet<>(Collections.singletonList("foo-domain"))); + setMapping(helper, mapping); + + Assert.assertEquals(Collections.singleton("foo-domain"), + helper.getDomains("workload/foo-mp/bar-app")); + } + + @Test + public void testC_GetDomainsSegmentPrefixWalkUpSingleMatch() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + Map> mapping = new HashMap<>(); + mapping.put("workload/foo-mp", new HashSet<>(Collections.singletonList("foo-domain"))); + setMapping(helper, mapping); + + Assert.assertEquals(Collections.singleton("foo-domain"), + helper.getDomains("workload/foo-mp/bar-app")); + } + + @Test + public void testC_GetDomainsSegmentPrefixWalkUpMultiSegmentUnion() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + Map> mapping = new HashMap<>(); + mapping.put("workload/foo-mp", new HashSet<>(Collections.singletonList("mp-domain"))); + mapping.put("workload/foo-mp/bar-app", + new HashSet<>(Collections.singletonList("app-domain"))); + setMapping(helper, mapping); + + Assert.assertEquals(new HashSet<>(Arrays.asList("mp-domain", "app-domain")), + helper.getDomains("workload/foo-mp/bar-app/some-tag")); + } + + @Test + public void testC_GetDomainsSegmentAlignmentIsStrict() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + Map> mapping = new HashMap<>(); + mapping.put("workload/foo-mp", new HashSet<>(Collections.singletonList("foo-domain"))); + setMapping(helper, mapping); + + Assert.assertEquals(Collections.emptySet(), helper.getDomains("workload/foo-mp-extra")); + } + + @Test + public void testC_GetDomainsUrnStyleNoWalkUp() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + Map> mapping = new HashMap<>(); + mapping.put("urn:li:servicePrincipal(bar;ei4;i001)", + new HashSet<>(Collections.singletonList("bar-domain"))); + setMapping(helper, mapping); + + Assert.assertEquals(Collections.emptySet(), + helper.getDomains("urn:li:servicePrincipal(foo;ei4;i001)")); + } + + @Test + public void testC_GetDomainsNullClientUri() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + Map> mapping = new HashMap<>(); + mapping.put("workload/foo-mp", new HashSet<>(Collections.singletonList("foo-domain"))); + setMapping(helper, mapping); + + Assert.assertEquals(Collections.emptySet(), helper.getDomains(null)); + } + + private static void setMapping(ZkClientUriDomainMappingHelper helper, + Map> mapping) { + helper.setClientUriToDomainNames(mapping); + } } diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java index 618d79a2ae2..92ab8030668 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java @@ -46,6 +46,7 @@ import javax.security.auth.x500.X500Principal; import org.apache.zookeeper.KeeperException; import org.apache.zookeeper.ZKTestCase; +import org.apache.zookeeper.common.SpiffeAuthTestUtil; import org.apache.zookeeper.server.MockServerCnxn; import org.apache.zookeeper.server.auth.X509AuthenticationConfig; import org.apache.zookeeper.server.auth.X509AuthenticationProvider; @@ -131,6 +132,210 @@ public void testSANBasedAuth() { X509AuthenticationConfig.reset(); } + // SPIFFE test fixtures + private static final String SPIFFE_V1_URI = "spiffe://prod.lipki/v1/wl/espresso-router"; + private static final String SPIFFE_V2_URI = "spiffe://prod.lipki/v2/application/espresso-router/espresso-router"; + + @Test + public void testSpiffeV1FallsBackToDn() { + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + try { + TestCertificate spiffeCert = new TestCertificate("CLIENT", SPIFFE_V1_URI); + X509AuthenticationProvider provider = createProvider(spiffeCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{spiffeCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + } finally { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + + @Test + public void testSpiffeV2Auth() { + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + try { + TestCertificate spiffeCert = new TestCertificate("CLIENT", SPIFFE_V2_URI); + X509AuthenticationProvider provider = createProvider(spiffeCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{spiffeCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("application/espresso-router/espresso-router", + cnxn.getAuthInfo().get(0).getId()); + } finally { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + + @Test + public void testSpiffeV2WorkloadAuth() { + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + try { + String spiffeWorkloadUri = "spiffe://prod.lipki/v2/workload/foo-mp/bar-app/some-tag"; + TestCertificate spiffeCert = new TestCertificate("CLIENT", spiffeWorkloadUri); + X509AuthenticationProvider provider = createProvider(spiffeCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{spiffeCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("workload/foo-mp/bar-app/some-tag", cnxn.getAuthInfo().get(0).getId()); + } finally { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + + @Test + public void testSpiffeNotConfiguredFallsBackToUrn() { + // SPIFFE regex NOT set — should fall through to URN-based SAN extraction + String urnSan = "urn:li:servicePrincipal(espresso-router;ei4;i001)"; + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, "^.*urn:li:.*$"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, + "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); + // SSL_X509_SPIFFE_SAN_MATCH_REGEX intentionally NOT set + + try { + TestCertificate urnCert = new TestCertificate("CLIENT", urnSan); + X509AuthenticationProvider provider = createProvider(urnCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{urnCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("servicePrincipal(espresso-router", cnxn.getAuthInfo().get(0).getId()); + } finally { + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); + X509AuthenticationConfig.reset(); + } + } + + @Test + public void testSpiffeConfiguredButNoSpiffeSanFallsBackToUrn() { + // SPIFFE regex set, but cert has URN SAN (not SPIFFE) → SPIFFE returns empty → falls back to URN + String urnSan = "urn:li:servicePrincipal(espresso-router;ei4;i001)"; + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, "^.*urn:li:.*$"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, + "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); + System.setProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX, SpiffeAuthTestUtil.SPIFFE_V2_MATCH_REGEX); + + try { + TestCertificate urnCert = new TestCertificate("CLIENT", urnSan); + X509AuthenticationProvider provider = createProvider(urnCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{urnCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + // URN SAN doesn't match spiffe://, so falls through to URN extraction + assertEquals("servicePrincipal(espresso-router", cnxn.getAuthInfo().get(0).getId()); + } finally { + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX); + X509AuthenticationConfig.reset(); + } + } + + @Test + public void testSpiffeV2UserIdentityRejected() { + // SPIFFE user identity (/v2/user/) must NOT be mapped to a service principal. + // It should be silently rejected by the SPIFFE extractor, fall through to URN (no match), + // then fall back to Subject DN. + String spiffeUserUri = "spiffe://prod.lipki/v2/user/alice"; + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + try { + TestCertificate userCert = new TestCertificate("CLIENT", spiffeUserUri); + X509AuthenticationProvider provider = createProvider(userCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{userCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + } finally { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + + @Test + public void testSpiffeV1UserIdentityRejected() { + // v1 user-identity now falls back to DN for two reasons: user-identity rejection AND v1 rejection. + String spiffeUserUri = "spiffe://prod.lipki/v1/user/alice"; + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + try { + TestCertificate userCert = new TestCertificate("CLIENT", spiffeUserUri); + X509AuthenticationProvider provider = createProvider(userCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{userCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + } finally { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + + @Test + public void testSpiffeMultipleSpiffeSansFallsBackToDn() { + // Cert with >1 SPIFFE SAN — extractor throws, caught in getClientId, falls through to URN + // (not configured) then to Subject DN. + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + try { + TestCertificate multiSanCert = new TestCertificate("CLIENT", + Arrays.asList(SPIFFE_V2_URI, "spiffe://prod.lipki/v2/application/another-service/another-service")); + X509AuthenticationProvider provider = createProvider(multiSanCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{multiSanCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + } finally { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + + @Test + public void testSpiffePrefersSpiffeOverUrnWhenBothPresent() { + // Cert has BOTH a URN-format SAN and a SPIFFE SAN. SPIFFE must win. + String urnSan = "urn:li:servicePrincipal(legacy-app;ei4;i001)"; + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, "^.*urn:li:.*$"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, + "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); + System.setProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX, SpiffeAuthTestUtil.SPIFFE_V2_MATCH_REGEX); + + try { + TestCertificate mixedCert = new TestCertificate("CLIENT", Arrays.asList(urnSan, SPIFFE_V2_URI)); + X509AuthenticationProvider provider = createProvider(mixedCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{mixedCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + // SPIFFE wins — path-after-/v2/, not the URN-derived legacy-app id. + assertEquals("application/espresso-router/espresso-router", + cnxn.getAuthInfo().get(0).getId()); + } finally { + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + protected static class TestPublicKey implements PublicKey { private static final long serialVersionUID = 1L; @@ -155,17 +360,21 @@ public static class TestCertificate extends X509Certificate { private byte[] encoded; private X500Principal principal; private PublicKey publicKey; - private String subjectAlternativeName; + private List subjectAlternativeNames; public TestCertificate(String name) { this(name, TEST_SAN_STR); } public TestCertificate(String name, String sanVal) { + this(name, Collections.singletonList(sanVal)); + } + + public TestCertificate(String name, List sanVals) { encoded = name.getBytes(); principal = new X500Principal("CN=" + name); publicKey = new TestPublicKey(); - subjectAlternativeName = sanVal; + subjectAlternativeNames = sanVals; } @Override public boolean hasUnsupportedCriticalExtension() { @@ -273,10 +482,14 @@ public X500Principal getSubjectX500Principal() { } @Override public Collection> getSubjectAlternativeNames() { - List subjectAlternativeNamePair = new ArrayList<>(); - subjectAlternativeNamePair.add(6); - subjectAlternativeNamePair.add(subjectAlternativeName); - return Collections.singletonList(subjectAlternativeNamePair); + List> result = new ArrayList<>(); + for (String san : subjectAlternativeNames) { + List pair = new ArrayList<>(); + pair.add(6); + pair.add(san); + result.add(pair); + } + return result; } } diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java new file mode 100644 index 00000000000..ca44ab16063 --- /dev/null +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java @@ -0,0 +1,139 @@ +/* + * 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.test; + +import static org.junit.Assert.assertEquals; +import java.security.cert.X509Certificate; +import org.apache.zookeeper.KeeperException; +import org.apache.zookeeper.ZKTestCase; +import org.apache.zookeeper.common.SpiffeAuthTestUtil; +import org.apache.zookeeper.server.MockServerCnxn; +import org.apache.zookeeper.server.auth.X509AuthenticationProvider; +import org.junit.After; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Integration tests for SPIFFE SAN-based client identity extraction. Unlike + * {@link X509AuthTest}, which uses a hand-rolled mock cert, these tests construct REAL + * {@link X509Certificate} instances (BouncyCastle-signed) with SPIFFE URI SANs and run them + * through the full {@link X509AuthenticationProvider#handleAuthentication} path. This exercises + * the JDK's actual SAN parsing, which the mock cert bypasses. + */ +public class X509SpiffeAuthIntegrationTest extends ZKTestCase { + + @BeforeClass + public static void registerBouncyCastle() { + SpiffeAuthTestUtil.registerBouncyCastle(); + } + + @After + public void tearDown() { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + + @Test + public void testRealCertWithSpiffeV1UriSanFallsBackToSubjectDn() throws Exception { + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v1/wl/espresso-router"); + + String id = runAuth(cert); + + assertEquals(cert.getSubjectX500Principal().getName(), id); + } + + @Test + public void testRealCertWithSpiffeV2UriSanIsExtracted() throws Exception { + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v2/application/espresso-router/espresso-router"); + + String id = runAuth(cert); + + assertEquals("application/espresso-router/espresso-router", id); + } + + @Test + public void testRealCertWithSpiffeUserUriFallsBackToSubjectDn() throws Exception { + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v2/user/alice"); + + String id = runAuth(cert); + + // User identity is rejected for service-principal extraction; falls through to URN + // (not configured here) and then to Subject DN. + assertEquals(cert.getSubjectX500Principal().getName(), id); + } + + @Test + public void testRealCertWithoutSpiffeSanFallsBackToSubjectDn() throws Exception { + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + // URI SAN that is not a SPIFFE URI — should not match the SPIFFE regex. + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "urn:li:servicePrincipal(legacy-app;ei4;i001)"); + + String id = runAuth(cert); + + // No SPIFFE match, URN extraction not configured here, so falls to Subject DN. + assertEquals(cert.getSubjectX500Principal().getName(), id); + } + + /** + * Defense-in-depth: a SAN whose single literal path segment contains {@code %2F} must not + * be silently promoted to a multi-segment principal via URI decoding (which could collide + * with an unrelated legitimate identity). Falls through to Subject DN. + */ + @Test + public void testRealCertWithPercentEncodedSlashInPathFallsBackToSubjectDn() throws Exception { + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v2/application%2Ffoo-mp%2Fbar-app"); + + String id = runAuth(cert); + + assertEquals(cert.getSubjectX500Principal().getName(), id); + } + + /** + * Defense-in-depth: a percent-encoded "user" segment ({@code %75ser}) must not bypass the + * user-identity rejection. Falls through to Subject DN. + */ + @Test + public void testRealCertWithPercentEncodedUserPathFallsBackToSubjectDn() throws Exception { + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v2/%75ser/alice"); + + String id = runAuth(cert); + + assertEquals(cert.getSubjectX500Principal().getName(), id); + } + + private static String runAuth(X509Certificate cert) { + X509AuthenticationProvider provider = new X509AuthenticationProvider( + new SpiffeAuthTestUtil.AcceptAllTrustManager(), + new SpiffeAuthTestUtil.NoopKeyManager()); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{cert}; + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + return cnxn.getAuthInfo().get(0).getId(); + } +} From 45a37760895ec7694b072cb0f210556cbcd0a6c1 Mon Sep 17 00:00:00 2001 From: Sanju98 Date: Thu, 14 May 2026 11:47:39 +0530 Subject: [PATCH 02/16] Address review: thread-safe SPIFFE config + wire prefix walk-up to production path Two fixes addressing the code review on PR #142: 1. X509AuthenticationConfig.getSpiffeSanMatchPattern now uses double-checked locking with volatile fields and a dedicated lock object, matching the pattern already used by allowedClientIdAsAclDomains and other lazy-loaded fields in the same class. The previous lazy-init was a data race on the per-handshake hot path. 2. X509ZNodeGroupAclProvider's setDomainAuthUpdater lambda now calls helper.getDomains(clientId) instead of the raw map's getOrDefault, so the segment-prefix walk-up added to ZkClientUriDomainMappingHelper is reachable from the production authentication path. This is the znode-tree analogue of LinkedIn's documented acl-tool wildcard idiom (`--spiffe "application//*"`); without this fix, MP-level prefix grants documented in the class javadoc would silently no-op. Adds testA4_SpiffeCertResolvesViaPrefixWalkUpToDomainAuthInfo: real SPIFFE cert with a 4-segment principal resolves to an MP-level leaf grant through the full provider->helper.getDomains path. This test would have failed before fix #2 (the exact-match lookup misses the 4-segment principal against a 2-segment registered prefix). All 30 SPIFFE-related tests pass (X509AuthTest 13 + X509SpiffeAuthIntegrationTest 6 + ZkClientUriDomainMappingHelperTest 11). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/auth/X509AuthenticationConfig.java | 16 ++++-- .../groupacl/X509ZNodeGroupAclProvider.java | 10 ++-- .../ZkClientUriDomainMappingHelperTest.java | 53 ++++++++++++++++++- 3 files changed, 70 insertions(+), 9 deletions(-) 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 ff1b80af44d..82e51e4beeb 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 @@ -109,8 +109,8 @@ public static X509AuthenticationConfig getInstance() { private String clientCertIdSanExtractRegex; private int clientCertIdSanExtractMatcherGroupIndex = -1; - private Pattern spiffeSanMatchPattern; - private boolean spiffeSanMatchPatternLoaded = false; + private volatile Pattern spiffeSanMatchPattern; + private volatile boolean spiffeSanMatchPatternLoaded = false; // ZooKeeper server-side config properties for ZNode group ACL feature @@ -196,6 +196,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 @@ -256,11 +257,18 @@ public void setSpiffeSanMatchRegex(String spiffeSanMatchRegex) { /** * Compiled SPIFFE SAN match pattern, or null if SPIFFE extraction is not configured. - * Loaded lazily from system properties on first access. + * 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) { - setSpiffeSanMatchRegex(System.getProperty(SSL_X509_SPIFFE_SAN_MATCH_REGEX)); + synchronized (spiffeSanMatchPatternLock) { + if (!spiffeSanMatchPatternLoaded) { + setSpiffeSanMatchRegex(System.getProperty(SSL_X509_SPIFFE_SAN_MATCH_REGEX)); + } + } } return spiffeSanMatchPattern; } 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/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 4850a37760a..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 @@ -76,7 +76,10 @@ public class ZkClientUriDomainMappingHelperTest extends ZKTestCase { 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-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; @@ -282,6 +285,54 @@ public void testA3_SpiffeCertResolvesThroughZNodeMappingToDomainAuthInfo() throw } } + /** + * End-to-end: a SPIFFE v2 client cert whose principal is a multi-segment ILM UID resolves to + * the correct {@code (x509, )} authInfo via the segment-prefix walk-up — the + * operator registered only the MP-level leaf, not the full app-level path. This mirrors the + * canonical LinkedIn ACL idiom (e.g. {@code acl-tool ... --spiffe "application//*"}) and + * ensures the prefix-walk-up is reachable from the production authentication path. + */ + @Test + public void testA4_SpiffeCertResolvesViaPrefixWalkUpToDomainAuthInfo() throws Exception { + String[] paths = { + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH, + 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" + }; + for (String path : paths) { + zookeeperClientConnection.create(path, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + } + + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + try { + // Cert's path-after-/v2/ is "application/helix-core/helix-controller/ltx1-tag" (4 segments), + // but the operator only registered the 2-segment leaf "application/helix-core". The walk-up + // must hit that prefix and grant the helix-mp-grant domain. + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v2/application/helix-core/helix-controller/ltx1-tag"); + + X509ZNodeGroupAclProvider provider = new X509ZNodeGroupAclProvider( + new SpiffeAuthTestUtil.AcceptAllTrustManager(), new SpiffeAuthTestUtil.NoopKeyManager()); + + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{cert}; + + KeeperException.Code result = provider.handleAuthentication( + new ServerAuthenticationProvider.ServerObjs(zookeeperServer, cnxn), null); + + Assert.assertEquals(KeeperException.Code.OK, result); + + boolean foundDomain = cnxn.getAuthInfo().stream() + .anyMatch(id -> "x509".equals(id.getScheme()) && "helix-mp-grant".equals(id.getId())); + Assert.assertTrue( + "Expected (x509, helix-mp-grant) in authInfo via prefix walk-up; actual: " + cnxn.getAuthInfo(), + foundDomain); + } finally { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + @Test /** * Make sure the watcher installed while instantiate ZkClientUriDomainMappingHelper does not break From 4a0533b95c1220a93c249644c4d44ca240a3f362 Mon Sep 17 00:00:00 2001 From: Sanju98 Date: Fri, 15 May 2026 10:25:56 +0530 Subject: [PATCH 03/16] Address review: accept SPIFFE v1 workload certs alongside v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @rgodha's review comment, the extractor now accepts both SPIFFE versions instead of rejecting v1 outright: - v2 (existing): spiffe:///v2/ → principal is the full path after /v2/ (the ILM UID, e.g. "application/foo-mp/bar-app"). - v1 workload (new): spiffe:///v1/wl/ → strip the "wl/" type prefix; principal is just the app-name. This matches how legacy authZ handled v1 identities. Other v1 paths (e.g. v1/wf/ workflow) still fall through to URN/DN. User-identity URIs (/v/user/...) continue to be rejected for both versions — they must never be promoted to a service principal. The test match regex broadens to ^spiffe://.*/v[12]/.*$ so existing test scaffolding exercises both versions. testSpiffeV1FallsBackToDn becomes testSpiffeV1WlAuth (now asserts extraction). The integration test for v1 likewise flips from fall-back-to-DN to v1/wl extraction. LISPIFFE-ID spec reference: https://github.com/linkedin-multiproduct/gopki/blob/master/LISPIFFE-ID.md#2-uri-path Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/auth/X509AuthenticationConfig.java | 20 +++++---- .../server/auth/X509AuthenticationUtil.java | 44 ++++++++++++------- .../zookeeper/common/SpiffeAuthTestUtil.java | 6 +-- .../apache/zookeeper/test/X509AuthTest.java | 10 +++-- .../test/X509SpiffeAuthIntegrationTest.java | 6 ++- 5 files changed, 54 insertions(+), 32 deletions(-) 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 82e51e4beeb..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 @@ -86,16 +86,20 @@ public static X509AuthenticationConfig getInstance() { public static final String SUBJECT_ALTERNATIVE_NAME_SHORT = "SAN"; /** - * Regex to identify SPIFFE URI SANs (type 6). When set, URI SANs (type 6) matching this regex - * are treated as SPIFFE identities; the principal is the path after {@code /v2/} (the ILM UID). - * v1 SPIFFE URIs and user-identity URIs ({@code /v/user/...}) are structurally rejected and - * fall through to URN/DN extraction regardless of this regex. - * If not set, SPIFFE extraction is disabled. + * 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 and require v2, e.g. - * {@code ^spiffe://prod\.lipki/v2/.*$}. A permissive regex like {@code ^spiffe://.*$} accepts + *

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; non-v2 URIs accepted by such a regex will still fall through. + * untrusted issuers. * *

ACL matching downstream is segment-prefix on the extracted UID; see * {@code X509AuthenticationUtil#matchAndExtractSpiffeSAN}. 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 0eba3c5d315..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 @@ -57,12 +57,17 @@ public class X509AuthenticationUtil extends X509Util { private static final Pattern SPIFFE_USER_IDENTITY_PATH_PATTERN = Pattern.compile("^/v\\d+/user(/.*)?$"); - // Matches LISPIFFE v2 workload paths and captures the ILM UID (the path after "/v2/"). + // 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. v1 SPIFFE URIs are deliberately not - // matched here — they are a deprecated design with app-name collision across MPs. + // 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; @@ -168,15 +173,20 @@ public static String getClientId(X509Certificate clientCert) { } /** - * Attempt to extract a client identity from a LISPIFFE v2 URI SAN. Returns the ILM UID — the - * path segment after {@code /v2/} — e.g. {@code spiffe://prod.lipki/v2/application/foo-mp/bar-app} - * yields {@code application/foo-mp/bar-app}. ACL matching downstream is segment-prefix on this - * UID. + * 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 v1 SPIFFE identity (deprecated; app-name collides - * across MPs), or the matched URI is a user identity ({@code /v/user/...}, which must never - * be promoted to a service principal). Caller falls through to URN/DN extraction. + * 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 */ @@ -216,12 +226,16 @@ private static Optional matchAndExtractSpiffeSAN(X509Certificate clientC return Optional.empty(); } Matcher v2Matcher = SPIFFE_V2_PATH_PATTERN.matcher(path); - if (!v2Matcher.matches()) { - LOG.debug("SPIFFE URI '{}' is not a v2 identity; falling through to URN/DN extraction.", - spiffeUri); - return Optional.empty(); + 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)); } - return Optional.of(v2Matcher.group(1)); + LOG.debug("SPIFFE URI '{}' is not a v1/wl or v2 identity; falling through to URN/DN extraction.", + spiffeUri); + return Optional.empty(); } /** 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 index a41f178bf99..4fcb9fe2945 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/common/SpiffeAuthTestUtil.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/common/SpiffeAuthTestUtil.java @@ -41,8 +41,8 @@ public final class SpiffeAuthTestUtil { public static final long ONE_DAY_MILLIS = 24L * 60 * 60 * 1000; - /** Match regex that accepts only v2 SPIFFE URIs (any trust domain). */ - public static final String SPIFFE_V2_MATCH_REGEX = "^spiffe://.*/v2/.*$"; + /** Match regex that accepts SPIFFE v1 and v2 URIs (any trust domain). */ + public static final String SPIFFE_MATCH_REGEX = "^spiffe://.*/v[12]/.*$"; private SpiffeAuthTestUtil() { } @@ -56,7 +56,7 @@ public static void registerBouncyCastle() { /** 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_V2_MATCH_REGEX); + System.setProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX, SPIFFE_MATCH_REGEX); X509AuthenticationConfig.reset(); } diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java index 92ab8030668..5355e8f7667 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java @@ -137,16 +137,18 @@ public void testSANBasedAuth() { private static final String SPIFFE_V2_URI = "spiffe://prod.lipki/v2/application/espresso-router/espresso-router"; @Test - public void testSpiffeV1FallsBackToDn() { + public void testSpiffeV1WlAuth() { SpiffeAuthTestUtil.setSpiffeSystemProperties(); try { + // SPIFFE_V1_URI = "spiffe://prod.lipki/v1/wl/espresso-router"; the "wl/" type prefix is + // stripped, principal is just the app-name. TestCertificate spiffeCert = new TestCertificate("CLIENT", SPIFFE_V1_URI); X509AuthenticationProvider provider = createProvider(spiffeCert); MockServerCnxn cnxn = new MockServerCnxn(); cnxn.clientChain = new X509Certificate[]{spiffeCert}; assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + assertEquals("espresso-router", cnxn.getAuthInfo().get(0).getId()); } finally { SpiffeAuthTestUtil.clearSpiffeSystemProperties(); } @@ -226,7 +228,7 @@ public void testSpiffeConfiguredButNoSpiffeSanFallsBackToUrn() { System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); - System.setProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX, SpiffeAuthTestUtil.SPIFFE_V2_MATCH_REGEX); + System.setProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX, SpiffeAuthTestUtil.SPIFFE_MATCH_REGEX); try { TestCertificate urnCert = new TestCertificate("CLIENT", urnSan); @@ -315,7 +317,7 @@ public void testSpiffePrefersSpiffeOverUrnWhenBothPresent() { System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); - System.setProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX, SpiffeAuthTestUtil.SPIFFE_V2_MATCH_REGEX); + System.setProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX, SpiffeAuthTestUtil.SPIFFE_MATCH_REGEX); try { TestCertificate mixedCert = new TestCertificate("CLIENT", Arrays.asList(urnSan, SPIFFE_V2_URI)); diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java index ca44ab16063..159d472b296 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java @@ -49,14 +49,16 @@ public void tearDown() { } @Test - public void testRealCertWithSpiffeV1UriSanFallsBackToSubjectDn() throws Exception { + public void testRealCertWithSpiffeV1WlUriSanIsExtracted() throws Exception { SpiffeAuthTestUtil.setSpiffeSystemProperties(); + // v1 workload path "/v1/wl/"; the "wl/" type prefix is stripped, principal is + // just the app-name. X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( "spiffe://prod.lipki/v1/wl/espresso-router"); String id = runAuth(cert); - assertEquals(cert.getSubjectX500Principal().getName(), id); + assertEquals("espresso-router", id); } @Test From 1daebd12264676d9d5473c0b003678ea1db03e1d Mon Sep 17 00:00:00 2001 From: Sanju98 Date: Wed, 1 Jul 2026 14:59:11 +0530 Subject: [PATCH 04/16] Fix SPIFFE v1/wl principal extraction to reject multi-segment values, and add regression tests for a real Grestin cert's dual urn:li: SAN scenario. --- .../server/auth/X509AuthenticationUtil.java | 6 +- .../apache/zookeeper/test/X509AuthTest.java | 92 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) 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 63c31f91b78..0274b932954 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 @@ -65,8 +65,10 @@ public class X509AuthenticationUtil extends X509Util { // 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/(.+)$"); + // principal, matching how legacy authZ systems handled v1 identities. The app-name is a + // single path segment (no "/"); a multi-segment value after "wl/" does not match here and + // falls through to URN/DN extraction instead of being misinterpreted as a single app-name. + private static final Pattern SPIFFE_V1_WL_PATH_PATTERN = Pattern.compile("^/v1/wl/([^/]+)$"); @Override protected String getConfigPrefix() { diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java index 5355e8f7667..c7cb395a8d0 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java @@ -154,6 +154,26 @@ public void testSpiffeV1WlAuth() { } } + @Test + public void testSpiffeV1WlMultiSegmentFallsBackToDn() { + // v1/wl app-name must be a single path segment. A multi-segment value after "wl/" (e.g. + // "a/b") must NOT be accepted as a single app-name principal; it should fall through to + // URN (not configured) then to Subject DN. + String spiffeMultiSegmentUri = "spiffe://prod.lipki/v1/wl/a/b"; + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + try { + TestCertificate spiffeCert = new TestCertificate("CLIENT", spiffeMultiSegmentUri); + X509AuthenticationProvider provider = createProvider(spiffeCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{spiffeCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + } finally { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + @Test public void testSpiffeV2Auth() { SpiffeAuthTestUtil.setSpiffeSystemProperties(); @@ -250,6 +270,78 @@ public void testSpiffeConfiguredButNoSpiffeSanFallsBackToUrn() { } } + @Test + public void testUrnMatchRegexTooBroadWithGrestinMetadataSanFallsBackToDn() { + // Real Grestin-issued service certs carry TWO urn:li: URIs in the same cert: + // servicePrincipal(...) and servicePrincipalMetadata(...). A loose match regex like + // "^.*urn:li:.*$" matches BOTH, which findSingleMatchingSan() rejects (requires exactly one + // match), causing a fall back to Subject DN instead of the intended service principal. + String servicePrincipalSan = "urn:li:servicePrincipal(zk-test-client;None;i001)"; + String servicePrincipalMetadataSan = "urn:li:servicePrincipalMetadata(dev;1.0.0)"; + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, "^.*urn:li:.*$"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, + "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); + + try { + TestCertificate grestinCert = new TestCertificate("CLIENT", + Arrays.asList(servicePrincipalSan, servicePrincipalMetadataSan)); + X509AuthenticationProvider provider = createProvider(grestinCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{grestinCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + // Multiple SAN matches -> extractor throws -> falls back to Subject DN. + assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + } finally { + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); + X509AuthenticationConfig.reset(); + } + } + + @Test + public void testUrnMatchRegexAnchoredToServicePrincipalExtractsCorrectlyWithGrestinMetadataSan() { + // Same two-SAN Grestin-style cert as above, but with a properly anchored match regex + // (matching only servicePrincipal, not servicePrincipalMetadata). This is the + // production-correct configuration and must yield exactly one match, extracting the + // service principal even with SPIFFE support also configured alongside it. + String servicePrincipalSan = "urn:li:servicePrincipal(zk-test-client;None;i001)"; + String servicePrincipalMetadataSan = "urn:li:servicePrincipalMetadata(dev;1.0.0)"; + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, + "^.*urn:li:servicePrincipal\\(.*$"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, + "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); + System.setProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX, SpiffeAuthTestUtil.SPIFFE_MATCH_REGEX); + + try { + TestCertificate grestinCert = new TestCertificate("CLIENT", + Arrays.asList(servicePrincipalSan, servicePrincipalMetadataSan)); + X509AuthenticationProvider provider = createProvider(grestinCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{grestinCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("servicePrincipal(zk-test-client", cnxn.getAuthInfo().get(0).getId()); + } finally { + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX); + X509AuthenticationConfig.reset(); + } + } + @Test public void testSpiffeV2UserIdentityRejected() { // SPIFFE user identity (/v2/user/) must NOT be mapped to a service principal. From e5e2b601c98e9f70619ed98b976499f2af0d3373 Mon Sep 17 00:00:00 2001 From: Sanju98 Date: Mon, 6 Jul 2026 15:11:58 +0530 Subject: [PATCH 05/16] Make SPIFFE URI-SAN principal extraction always-on, not a feature flag Previously SPIFFE detection was gated behind the opt-in ssl.x509.spiffe.sanMatchRegex system property and was a no-op unless an operator explicitly configured it. This removes that config knob entirely: X509AuthenticationUtil#getClientId now checks for a spiffe:// URI SAN unconditionally, before the clientCertIdType-gated legacy URN fallback, regardless of how (or whether) clientCertIdType is configured. - Remove SSL_X509_SPIFFE_SAN_MATCH_REGEX and its lazy-loaded Pattern field/getter/setter from X509AuthenticationConfig. - X509AuthenticationUtil: replace the configurable SPIFFE match pattern with an unconditional constant and run SPIFFE detection first, unconditionally, in getClientId(). - Update SpiffeAuthTestUtil and existing SPIFFE tests to drop references to the removed config property. - Add regression tests proving SPIFFE v1/v2 extraction and SPIFFE user-identity rejection work with zero clientCertIdType configuration (X509AuthTest, X509SpiffeAuthIntegrationTest). --- .../server/auth/X509AuthenticationConfig.java | 50 ------------- .../server/auth/X509AuthenticationUtil.java | 49 ++++++------ .../zookeeper/common/SpiffeAuthTestUtil.java | 14 ++-- .../apache/zookeeper/test/X509AuthTest.java | 75 +++++++++++++++++-- .../test/X509SpiffeAuthIntegrationTest.java | 17 +++++ 5 files changed, 119 insertions(+), 86 deletions(-) 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 76a4459e418..e5a95d9ea6a 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,7 +24,6 @@ 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; @@ -85,27 +84,6 @@ public static X509AuthenticationConfig getInstance() { 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; @@ -113,9 +91,6 @@ 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 /** @@ -200,7 +175,6 @@ 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 @@ -253,30 +227,6 @@ 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) { 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 0274b932954..767d1eaf7a2 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 @@ -50,6 +50,11 @@ public class X509AuthenticationUtil extends X509Util { public static final String SUPERUSER_AUTH_SCHEME = "super"; public static final String X509_SCHEME = "x509"; + // Matches any SPIFFE URI, regardless of trust domain or version. SPIFFE detection is always + // active — not gated behind operator config — and relies entirely on the TLS trust manager to + // reject certificates from untrusted issuers before this code is ever reached. + private static final Pattern SPIFFE_URI_PATTERN = Pattern.compile("^spiffe://.*$"); + // 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. @@ -153,18 +158,23 @@ public static X509TrustManager createTrustManager(ZKConfig config) { * The clientId string is intended to be an URI for client and map the client to certain domain. */ public static String getClientId(X509Certificate clientCert) { + // SPIFFE identity extraction always runs, regardless of clientCertIdType configuration — + // it is not a feature flag. Any URI SAN beginning with "spiffe://" is treated as a + // candidate; trust in the issuing CA/trust-domain is established upstream by the TLS + // handshake's trust manager, not by this method. + 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 legacy extraction.", e); + } + 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) { @@ -175,7 +185,9 @@ public static String getClientId(X509Certificate clientCert) { } /** - * Attempt to extract a client identity from a LISPIFFE URI SAN. Supported forms: + * Attempt to extract a client identity from a LISPIFFE URI SAN. Always active — not gated + * behind any operator configuration. Any URI SAN beginning with {@code spiffe://} is treated + * as a candidate. 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} → @@ -185,21 +197,16 @@ public static String getClientId(X509Certificate clientCert) { * 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. + *

Returns {@link Optional#empty()} when no URI SAN begins with {@code spiffe://}, 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 + * @throws IllegalArgumentException if multiple URI SANs begin with {@code spiffe://} */ 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"); + String spiffeUri = findSingleMatchingSan(clientCert, 6, SPIFFE_URI_PATTERN, "SPIFFE"); if (spiffeUri == null) { return Optional.empty(); } 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 index 4fcb9fe2945..f50a96feab7 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/common/SpiffeAuthTestUtil.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/common/SpiffeAuthTestUtil.java @@ -35,14 +35,16 @@ /** * 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. + * setup/teardown for SAN-based extraction, real X509 client cert builder with URI SANs, and stub + * TLS managers. Shared across SPIFFE auth tests. + * + *

Note: SPIFFE identity extraction itself is always active (not gated behind any config), so + * {@link #setSpiffeSystemProperties()} only needs to configure {@code clientCertIdType=SAN}, + * which some tests in this suite also exercise for legacy URN fallback behavior. */ 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() { } @@ -53,16 +55,14 @@ public static void registerBouncyCastle() { } } - /** Configures SAN+SPIFFE-v2 extraction in the X509AuthenticationConfig singleton. */ + /** Configures SAN-based 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(); } diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java index c7cb395a8d0..ca81d916ea0 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java @@ -19,6 +19,7 @@ package org.apache.zookeeper.test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import com.google.common.annotations.VisibleForTesting; import java.math.BigInteger; import java.net.Socket; @@ -136,6 +137,66 @@ public void testSANBasedAuth() { private static final String SPIFFE_V1_URI = "spiffe://prod.lipki/v1/wl/espresso-router"; private static final String SPIFFE_V2_URI = "spiffe://prod.lipki/v2/application/espresso-router/espresso-router"; + @Test + public void testSpiffeV1ExtractedWithoutAnyClientCertIdTypeConfigured() { + // Core "not a feature flag" guarantee: SPIFFE detection runs regardless of + // clientCertIdType. No system properties are set at all here (not even + // clientCertIdType=SAN) — the cert's spiffe:// URI SAN must still be recognized and + // extracted without any operator configuration. + assertNull("Test must start with no clientCertIdType configured", + System.getProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE)); + try { + TestCertificate spiffeCert = new TestCertificate("CLIENT", SPIFFE_V1_URI); + X509AuthenticationProvider provider = createProvider(spiffeCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{spiffeCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("espresso-router", cnxn.getAuthInfo().get(0).getId()); + } finally { + X509AuthenticationConfig.reset(); + } + } + + @Test + public void testSpiffeV2ExtractedWithoutAnyClientCertIdTypeConfigured() { + // Same guarantee as above, for the v2 (full ILM UID) form. + assertNull("Test must start with no clientCertIdType configured", + System.getProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE)); + try { + TestCertificate spiffeCert = new TestCertificate("CLIENT", SPIFFE_V2_URI); + X509AuthenticationProvider provider = createProvider(spiffeCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{spiffeCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("application/espresso-router/espresso-router", + cnxn.getAuthInfo().get(0).getId()); + } finally { + X509AuthenticationConfig.reset(); + } + } + + @Test + public void testSpiffeV2UserIdentityRejectedWithoutAnyClientCertIdTypeConfigured() { + // Rejection of user identities must also hold with zero configuration — a human's SPIFFE + // cert must never be promoted to a service principal, feature flag or not. + String spiffeUserUri = "spiffe://prod.lipki/v2/user/alice"; + assertNull("Test must start with no clientCertIdType configured", + System.getProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE)); + try { + TestCertificate userCert = new TestCertificate("CLIENT", spiffeUserUri); + X509AuthenticationProvider provider = createProvider(userCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{userCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + } finally { + X509AuthenticationConfig.reset(); + } + } + @Test public void testSpiffeV1WlAuth() { SpiffeAuthTestUtil.setSpiffeSystemProperties(); @@ -210,7 +271,8 @@ public void testSpiffeV2WorkloadAuth() { @Test public void testSpiffeNotConfiguredFallsBackToUrn() { - // SPIFFE regex NOT set — should fall through to URN-based SAN extraction + // Cert has a URN SAN (not a spiffe:// URI), so the always-on SPIFFE check finds no match and + // falls through to legacy URN-based SAN extraction. String urnSan = "urn:li:servicePrincipal(espresso-router;ei4;i001)"; System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); @@ -218,7 +280,7 @@ public void testSpiffeNotConfiguredFallsBackToUrn() { System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); - // SSL_X509_SPIFFE_SAN_MATCH_REGEX intentionally NOT set + // SPIFFE detection is always on (not config-gated); it simply finds no spiffe:// SAN here. try { TestCertificate urnCert = new TestCertificate("CLIENT", urnSan); @@ -240,7 +302,7 @@ public void testSpiffeNotConfiguredFallsBackToUrn() { @Test public void testSpiffeConfiguredButNoSpiffeSanFallsBackToUrn() { - // SPIFFE regex set, but cert has URN SAN (not SPIFFE) → SPIFFE returns empty → falls back to URN + // Cert has a URN SAN (not SPIFFE) → the always-on SPIFFE check returns empty → falls back to URN String urnSan = "urn:li:servicePrincipal(espresso-router;ei4;i001)"; System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); @@ -248,7 +310,6 @@ public void testSpiffeConfiguredButNoSpiffeSanFallsBackToUrn() { System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); - System.setProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX, SpiffeAuthTestUtil.SPIFFE_MATCH_REGEX); try { TestCertificate urnCert = new TestCertificate("CLIENT", urnSan); @@ -265,7 +326,6 @@ public void testSpiffeConfiguredButNoSpiffeSanFallsBackToUrn() { System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); - System.clearProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX); X509AuthenticationConfig.reset(); } } @@ -320,7 +380,7 @@ public void testUrnMatchRegexAnchoredToServicePrincipalExtractsCorrectlyWithGres System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); - System.setProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX, SpiffeAuthTestUtil.SPIFFE_MATCH_REGEX); + // SPIFFE detection is always on; this cert has no spiffe:// SAN, so it's unaffected. try { TestCertificate grestinCert = new TestCertificate("CLIENT", @@ -337,7 +397,6 @@ public void testUrnMatchRegexAnchoredToServicePrincipalExtractsCorrectlyWithGres System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); - System.clearProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX); X509AuthenticationConfig.reset(); } } @@ -409,7 +468,7 @@ public void testSpiffePrefersSpiffeOverUrnWhenBothPresent() { System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); - System.setProperty(X509AuthenticationConfig.SSL_X509_SPIFFE_SAN_MATCH_REGEX, SpiffeAuthTestUtil.SPIFFE_MATCH_REGEX); + // SPIFFE detection is always on and is tried first, so it wins regardless of URN config. try { TestCertificate mixedCert = new TestCertificate("CLIENT", Arrays.asList(urnSan, SPIFFE_V2_URI)); diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java index 159d472b296..709ccda8a81 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java @@ -19,11 +19,13 @@ package org.apache.zookeeper.test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import java.security.cert.X509Certificate; import org.apache.zookeeper.KeeperException; import org.apache.zookeeper.ZKTestCase; import org.apache.zookeeper.common.SpiffeAuthTestUtil; import org.apache.zookeeper.server.MockServerCnxn; +import org.apache.zookeeper.server.auth.X509AuthenticationConfig; import org.apache.zookeeper.server.auth.X509AuthenticationProvider; import org.junit.After; import org.junit.BeforeClass; @@ -72,6 +74,21 @@ public void testRealCertWithSpiffeV2UriSanIsExtracted() throws Exception { assertEquals("application/espresso-router/espresso-router", id); } + @Test + public void testRealCertWithSpiffeV2UriSanIsExtractedWithoutAnyConfiguration() throws Exception { + // Core "not a feature flag" guarantee, exercised against a real BouncyCastle-signed + // cert (not the hand-rolled mock in X509AuthTest): SPIFFE detection must succeed even + // with zero system properties set — not even clientCertIdType=SAN. + assertNull("Test must start with no clientCertIdType configured", + System.getProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE)); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v2/application/espresso-router/espresso-router"); + + String id = runAuth(cert); + + assertEquals("application/espresso-router/espresso-router", id); + } + @Test public void testRealCertWithSpiffeUserUriFallsBackToSubjectDn() throws Exception { SpiffeAuthTestUtil.setSpiffeSystemProperties(); From 5e1d22ba16e265bac389222e53136562a4cf6d2f Mon Sep 17 00:00:00 2001 From: Sanju98 Date: Tue, 14 Jul 2026 14:53:50 +0530 Subject: [PATCH 06/16] Add v1 application/airflow workload sub-type support to SPIFFE extraction LISPIFFE-ID spec section 2.A lists application/<...> and airflow/<....> as valid v1 workload sub-types alongside wl/, which were not previously recognized. Per rgodha's review comment on PR #142, extend the v1 extractor to accept these forms, retaining the type prefix in the principal (matching v2 semantics), while wl/ keeps its existing bare-app-name behavior. v1/wf/ (Flyte workflow) remains out of scope. Adds 6 new tests across X509AuthTest and X509SpiffeAuthIntegrationTest. --- .../server/auth/X509AuthenticationUtil.java | 42 ++++++++++---- .../apache/zookeeper/test/X509AuthTest.java | 58 +++++++++++++++++++ .../test/X509SpiffeAuthIntegrationTest.java | 38 ++++++++++++ 3 files changed, 126 insertions(+), 12 deletions(-) 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 767d1eaf7a2..f42ed7deba0 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 @@ -67,14 +67,22 @@ public class X509AuthenticationUtil extends X509Util { // 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. The app-name is a - // single path segment (no "/"); a multi-segment value after "wl/" does not match here and - // falls through to URN/DN extraction instead of being misinterpreted as a single app-name. + // Matches the legacy LISPIFFE v1 "wl/" workload form 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. The app-name is a single path segment (no "/"); a + // multi-segment value after "wl/" does not match here and falls through to URN/DN extraction + // instead of being misinterpreted as a single app-name. private static final Pattern SPIFFE_V1_WL_PATH_PATTERN = Pattern.compile("^/v1/wl/([^/]+)$"); + // Matches the other LISPIFFE v1 workload sub-types (LISPIFFE-ID spec §2.A: "application/<...>" + // and "airflow/<....>", alongside "wl/"). Unlike "wl/", these keep their type prefix in the + // extracted principal (e.g. "/v1/application/foo-mp/bar-app" -> "application/foo-mp/bar-app"), + // matching how v2 identities are handled. Deliberately excludes "wf/" (v1 Flyte workflow), + // which is out of scope for ZK per PR #142 review discussion. + private static final Pattern SPIFFE_V1_WORKLOAD_PATH_PATTERN = + Pattern.compile("^/v1/(application|airflow)/(.+)$"); + @Override protected String getConfigPrefix() { return X509AuthenticationConfig.SSL_X509_CONFIG_PREFIX; @@ -192,15 +200,21 @@ public static String getClientId(X509Certificate clientCert) { *

  • 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 + *
  • v1 workload, {@code wl} form ({@code spiffe:///v1/wl/}): principal is + * just the {@code } (the "wl/" type prefix is stripped, matching how legacy authZ * handled v1 identities).
  • + *
  • v1 workload, {@code application}/{@code airflow} forms + * ({@code spiffe:///v1/application/} or {@code spiffe:///v1/airflow/}): + * principal is the full path including the type prefix, e.g. + * {@code spiffe:///v1/application/foo-mp/bar-app} → {@code application/foo-mp/bar-app} + * (see LISPIFFE-ID spec §2.A).
  • * * *

    Returns {@link Optional#empty()} when no URI SAN begins with {@code spiffe://}, 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. + * service principal), or the matched URI is a non-{v1 wl/application/airflow, v2} path (e.g. + * v1 Flyte workflow {@code /v1/wf/...}, out of scope for ZK). Caller falls through to URN/DN + * extraction. * * @throws IllegalArgumentException if multiple URI SANs begin with {@code spiffe://} */ @@ -242,8 +256,12 @@ private static Optional matchAndExtractSpiffeSAN(X509Certificate clientC 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); + Matcher v1WorkloadMatcher = SPIFFE_V1_WORKLOAD_PATH_PATTERN.matcher(path); + if (v1WorkloadMatcher.matches()) { + return Optional.of(v1WorkloadMatcher.group(1) + "/" + v1WorkloadMatcher.group(2)); + } + LOG.debug("SPIFFE URI '{}' is not a v1/wl, v1/application, v1/airflow, or v2 identity; " + + "falling through to URN/DN extraction.", spiffeUri); return Optional.empty(); } diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java index ca81d916ea0..f2f378bb6de 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java @@ -235,6 +235,64 @@ public void testSpiffeV1WlMultiSegmentFallsBackToDn() { } } + @Test + public void testSpiffeV1ApplicationAuth() { + // LISPIFFE-ID spec §2.A: v1 also supports "application/<...>" as a workload sub-type + // alongside "wl/". Unlike "wl/", the type prefix is retained in the extracted principal. + String spiffeV1ApplicationUri = "spiffe://prod.lipki/v1/application/foo-mp/bar-app"; + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + try { + TestCertificate spiffeCert = new TestCertificate("CLIENT", spiffeV1ApplicationUri); + X509AuthenticationProvider provider = createProvider(spiffeCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{spiffeCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("application/foo-mp/bar-app", cnxn.getAuthInfo().get(0).getId()); + } finally { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + + @Test + public void testSpiffeV1AirflowAuth() { + // LISPIFFE-ID spec §2.A: v1 also supports "airflow/<....>" for Airflow DAG workloads. + // Same retained-prefix principal semantics as "application/". + String spiffeV1AirflowUri = "spiffe://prod.lipki/v1/airflow/my-dag"; + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + try { + TestCertificate spiffeCert = new TestCertificate("CLIENT", spiffeV1AirflowUri); + X509AuthenticationProvider provider = createProvider(spiffeCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{spiffeCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("airflow/my-dag", cnxn.getAuthInfo().get(0).getId()); + } finally { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + + @Test + public void testSpiffeV1WorkflowFallsBackToDn() { + // v1 Flyte workflow ("wf/") remains explicitly out of scope for ZK (per PR #142 review + // discussion) and must still fall through to URN/DN, not be swept up by the new + // application/airflow handling. + String spiffeV1WorkflowUri = "spiffe://prod.lipki/v1/wf/some-workflow"; + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + try { + TestCertificate spiffeCert = new TestCertificate("CLIENT", spiffeV1WorkflowUri); + X509AuthenticationProvider provider = createProvider(spiffeCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{spiffeCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + } finally { + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + @Test public void testSpiffeV2Auth() { SpiffeAuthTestUtil.setSpiffeSystemProperties(); diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java index 709ccda8a81..62f0690b2b6 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java @@ -63,6 +63,44 @@ public void testRealCertWithSpiffeV1WlUriSanIsExtracted() throws Exception { assertEquals("espresso-router", id); } + @Test + public void testRealCertWithSpiffeV1ApplicationUriSanIsExtracted() throws Exception { + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + // LISPIFFE-ID spec §2.A: v1 also supports "application/<...>"; unlike "wl/", the type + // prefix is retained in the extracted principal. + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v1/application/foo-mp/bar-app"); + + String id = runAuth(cert); + + assertEquals("application/foo-mp/bar-app", id); + } + + @Test + public void testRealCertWithSpiffeV1AirflowUriSanIsExtracted() throws Exception { + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + // LISPIFFE-ID spec §2.A: v1 also supports "airflow/<....>" for Airflow DAG workloads. + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v1/airflow/my-dag"); + + String id = runAuth(cert); + + assertEquals("airflow/my-dag", id); + } + + @Test + public void testRealCertWithSpiffeV1WorkflowUriSanFallsBackToSubjectDn() throws Exception { + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + // v1 Flyte workflow ("wf/") remains out of scope for ZK and must fall through, even + // though "application/" and "airflow/" are now recognized. + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v1/wf/some-workflow"); + + String id = runAuth(cert); + + assertEquals(cert.getSubjectX500Principal().getName(), id); + } + @Test public void testRealCertWithSpiffeV2UriSanIsExtracted() throws Exception { SpiffeAuthTestUtil.setSpiffeSystemProperties(); From 110b99f0fb85435052bb5649de0b21009b3999d3 Mon Sep 17 00:00:00 2001 From: Sanju98 Date: Thu, 16 Jul 2026 15:00:10 +0530 Subject: [PATCH 07/16] Consolidate SPIFFE auth tests into real-cert integration suite X509AuthTest previously duplicated most SPIFFE URI-SAN extraction scenarios using TestCertificate, a hand-rolled X509Certificate whose getSubjectAlternativeNames() just returns a canned list -- it never exercises real ASN.1/SAN encoding or the JDK's certificate parsing. X509SpiffeAuthIntegrationTest already covered several of the same scenarios using real BouncyCastle-signed certs, but had a few gaps. Changes: - Added 8 real-cert tests to X509SpiffeAuthIntegrationTest to close the coverage gaps: v1/wl zero-config, v1/wl multi-segment fallback, v1/user rejection, v2/workload extraction, v2/user zero-config rejection, non-SPIFFE-SAN-falls-back-to-URN (with URN configured), multiple-SPIFFE-SANs fallback, and SPIFFE-wins-over-URN precedence. - Removed the now-redundant SPIFFE-specific mock tests from X509AuthTest (17 tests across ~230 lines), along with the SPIFFE_V1_URI/SPIFFE_V2_URI fixtures and now-unused imports. X509AuthTest keeps only the generic (non-SPIFFE) auth/SAN-regex tests, which still use the lightweight fake certificate since they don't need real certificate parsing. - X509SpiffeAuthIntegrationTest is now the authoritative, real-cert suite for SPIFFE certificate validation and principal extraction. Verified: X509AuthTest (6 tests) + X509SpiffeAuthIntegrationTest (18 tests, up from 10) all pass. --- .../apache/zookeeper/test/X509AuthTest.java | 345 ------------------ .../test/X509SpiffeAuthIntegrationTest.java | 148 +++++++- 2 files changed, 143 insertions(+), 350 deletions(-) diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java index f2f378bb6de..8df45a45d48 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java @@ -19,7 +19,6 @@ package org.apache.zookeeper.test; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; import com.google.common.annotations.VisibleForTesting; import java.math.BigInteger; import java.net.Socket; @@ -47,7 +46,6 @@ import javax.security.auth.x500.X500Principal; import org.apache.zookeeper.KeeperException; import org.apache.zookeeper.ZKTestCase; -import org.apache.zookeeper.common.SpiffeAuthTestUtil; import org.apache.zookeeper.server.MockServerCnxn; import org.apache.zookeeper.server.auth.X509AuthenticationConfig; import org.apache.zookeeper.server.auth.X509AuthenticationProvider; @@ -133,261 +131,6 @@ public void testSANBasedAuth() { X509AuthenticationConfig.reset(); } - // SPIFFE test fixtures - private static final String SPIFFE_V1_URI = "spiffe://prod.lipki/v1/wl/espresso-router"; - private static final String SPIFFE_V2_URI = "spiffe://prod.lipki/v2/application/espresso-router/espresso-router"; - - @Test - public void testSpiffeV1ExtractedWithoutAnyClientCertIdTypeConfigured() { - // Core "not a feature flag" guarantee: SPIFFE detection runs regardless of - // clientCertIdType. No system properties are set at all here (not even - // clientCertIdType=SAN) — the cert's spiffe:// URI SAN must still be recognized and - // extracted without any operator configuration. - assertNull("Test must start with no clientCertIdType configured", - System.getProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE)); - try { - TestCertificate spiffeCert = new TestCertificate("CLIENT", SPIFFE_V1_URI); - X509AuthenticationProvider provider = createProvider(spiffeCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{spiffeCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("espresso-router", cnxn.getAuthInfo().get(0).getId()); - } finally { - X509AuthenticationConfig.reset(); - } - } - - @Test - public void testSpiffeV2ExtractedWithoutAnyClientCertIdTypeConfigured() { - // Same guarantee as above, for the v2 (full ILM UID) form. - assertNull("Test must start with no clientCertIdType configured", - System.getProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE)); - try { - TestCertificate spiffeCert = new TestCertificate("CLIENT", SPIFFE_V2_URI); - X509AuthenticationProvider provider = createProvider(spiffeCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{spiffeCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("application/espresso-router/espresso-router", - cnxn.getAuthInfo().get(0).getId()); - } finally { - X509AuthenticationConfig.reset(); - } - } - - @Test - public void testSpiffeV2UserIdentityRejectedWithoutAnyClientCertIdTypeConfigured() { - // Rejection of user identities must also hold with zero configuration — a human's SPIFFE - // cert must never be promoted to a service principal, feature flag or not. - String spiffeUserUri = "spiffe://prod.lipki/v2/user/alice"; - assertNull("Test must start with no clientCertIdType configured", - System.getProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE)); - try { - TestCertificate userCert = new TestCertificate("CLIENT", spiffeUserUri); - X509AuthenticationProvider provider = createProvider(userCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{userCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); - } finally { - X509AuthenticationConfig.reset(); - } - } - - @Test - public void testSpiffeV1WlAuth() { - SpiffeAuthTestUtil.setSpiffeSystemProperties(); - try { - // SPIFFE_V1_URI = "spiffe://prod.lipki/v1/wl/espresso-router"; the "wl/" type prefix is - // stripped, principal is just the app-name. - TestCertificate spiffeCert = new TestCertificate("CLIENT", SPIFFE_V1_URI); - X509AuthenticationProvider provider = createProvider(spiffeCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{spiffeCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("espresso-router", cnxn.getAuthInfo().get(0).getId()); - } finally { - SpiffeAuthTestUtil.clearSpiffeSystemProperties(); - } - } - - @Test - public void testSpiffeV1WlMultiSegmentFallsBackToDn() { - // v1/wl app-name must be a single path segment. A multi-segment value after "wl/" (e.g. - // "a/b") must NOT be accepted as a single app-name principal; it should fall through to - // URN (not configured) then to Subject DN. - String spiffeMultiSegmentUri = "spiffe://prod.lipki/v1/wl/a/b"; - SpiffeAuthTestUtil.setSpiffeSystemProperties(); - try { - TestCertificate spiffeCert = new TestCertificate("CLIENT", spiffeMultiSegmentUri); - X509AuthenticationProvider provider = createProvider(spiffeCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{spiffeCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); - } finally { - SpiffeAuthTestUtil.clearSpiffeSystemProperties(); - } - } - - @Test - public void testSpiffeV1ApplicationAuth() { - // LISPIFFE-ID spec §2.A: v1 also supports "application/<...>" as a workload sub-type - // alongside "wl/". Unlike "wl/", the type prefix is retained in the extracted principal. - String spiffeV1ApplicationUri = "spiffe://prod.lipki/v1/application/foo-mp/bar-app"; - SpiffeAuthTestUtil.setSpiffeSystemProperties(); - try { - TestCertificate spiffeCert = new TestCertificate("CLIENT", spiffeV1ApplicationUri); - X509AuthenticationProvider provider = createProvider(spiffeCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{spiffeCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("application/foo-mp/bar-app", cnxn.getAuthInfo().get(0).getId()); - } finally { - SpiffeAuthTestUtil.clearSpiffeSystemProperties(); - } - } - - @Test - public void testSpiffeV1AirflowAuth() { - // LISPIFFE-ID spec §2.A: v1 also supports "airflow/<....>" for Airflow DAG workloads. - // Same retained-prefix principal semantics as "application/". - String spiffeV1AirflowUri = "spiffe://prod.lipki/v1/airflow/my-dag"; - SpiffeAuthTestUtil.setSpiffeSystemProperties(); - try { - TestCertificate spiffeCert = new TestCertificate("CLIENT", spiffeV1AirflowUri); - X509AuthenticationProvider provider = createProvider(spiffeCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{spiffeCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("airflow/my-dag", cnxn.getAuthInfo().get(0).getId()); - } finally { - SpiffeAuthTestUtil.clearSpiffeSystemProperties(); - } - } - - @Test - public void testSpiffeV1WorkflowFallsBackToDn() { - // v1 Flyte workflow ("wf/") remains explicitly out of scope for ZK (per PR #142 review - // discussion) and must still fall through to URN/DN, not be swept up by the new - // application/airflow handling. - String spiffeV1WorkflowUri = "spiffe://prod.lipki/v1/wf/some-workflow"; - SpiffeAuthTestUtil.setSpiffeSystemProperties(); - try { - TestCertificate spiffeCert = new TestCertificate("CLIENT", spiffeV1WorkflowUri); - X509AuthenticationProvider provider = createProvider(spiffeCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{spiffeCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); - } finally { - SpiffeAuthTestUtil.clearSpiffeSystemProperties(); - } - } - - @Test - public void testSpiffeV2Auth() { - SpiffeAuthTestUtil.setSpiffeSystemProperties(); - try { - TestCertificate spiffeCert = new TestCertificate("CLIENT", SPIFFE_V2_URI); - X509AuthenticationProvider provider = createProvider(spiffeCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{spiffeCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("application/espresso-router/espresso-router", - cnxn.getAuthInfo().get(0).getId()); - } finally { - SpiffeAuthTestUtil.clearSpiffeSystemProperties(); - } - } - - @Test - public void testSpiffeV2WorkloadAuth() { - SpiffeAuthTestUtil.setSpiffeSystemProperties(); - try { - String spiffeWorkloadUri = "spiffe://prod.lipki/v2/workload/foo-mp/bar-app/some-tag"; - TestCertificate spiffeCert = new TestCertificate("CLIENT", spiffeWorkloadUri); - X509AuthenticationProvider provider = createProvider(spiffeCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{spiffeCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("workload/foo-mp/bar-app/some-tag", cnxn.getAuthInfo().get(0).getId()); - } finally { - SpiffeAuthTestUtil.clearSpiffeSystemProperties(); - } - } - - @Test - public void testSpiffeNotConfiguredFallsBackToUrn() { - // Cert has a URN SAN (not a spiffe:// URI), so the always-on SPIFFE check finds no match and - // falls through to legacy URN-based SAN extraction. - String urnSan = "urn:li:servicePrincipal(espresso-router;ei4;i001)"; - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, "^.*urn:li:.*$"); - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, - "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); - // SPIFFE detection is always on (not config-gated); it simply finds no spiffe:// SAN here. - - try { - TestCertificate urnCert = new TestCertificate("CLIENT", urnSan); - X509AuthenticationProvider provider = createProvider(urnCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{urnCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("servicePrincipal(espresso-router", cnxn.getAuthInfo().get(0).getId()); - } finally { - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE); - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); - X509AuthenticationConfig.reset(); - } - } - - @Test - public void testSpiffeConfiguredButNoSpiffeSanFallsBackToUrn() { - // Cert has a URN SAN (not SPIFFE) → the always-on SPIFFE check returns empty → falls back to URN - String urnSan = "urn:li:servicePrincipal(espresso-router;ei4;i001)"; - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, "^.*urn:li:.*$"); - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, - "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); - - try { - TestCertificate urnCert = new TestCertificate("CLIENT", urnSan); - X509AuthenticationProvider provider = createProvider(urnCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{urnCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - // URN SAN doesn't match spiffe://, so falls through to URN extraction - assertEquals("servicePrincipal(espresso-router", cnxn.getAuthInfo().get(0).getId()); - } finally { - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE); - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); - X509AuthenticationConfig.reset(); - } - } - @Test public void testUrnMatchRegexTooBroadWithGrestinMetadataSanFallsBackToDn() { // Real Grestin-issued service certs carry TWO urn:li: URIs in the same cert: @@ -459,94 +202,6 @@ public void testUrnMatchRegexAnchoredToServicePrincipalExtractsCorrectlyWithGres } } - @Test - public void testSpiffeV2UserIdentityRejected() { - // SPIFFE user identity (/v2/user/) must NOT be mapped to a service principal. - // It should be silently rejected by the SPIFFE extractor, fall through to URN (no match), - // then fall back to Subject DN. - String spiffeUserUri = "spiffe://prod.lipki/v2/user/alice"; - SpiffeAuthTestUtil.setSpiffeSystemProperties(); - try { - TestCertificate userCert = new TestCertificate("CLIENT", spiffeUserUri); - X509AuthenticationProvider provider = createProvider(userCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{userCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); - } finally { - SpiffeAuthTestUtil.clearSpiffeSystemProperties(); - } - } - - @Test - public void testSpiffeV1UserIdentityRejected() { - // v1 user-identity now falls back to DN for two reasons: user-identity rejection AND v1 rejection. - String spiffeUserUri = "spiffe://prod.lipki/v1/user/alice"; - SpiffeAuthTestUtil.setSpiffeSystemProperties(); - try { - TestCertificate userCert = new TestCertificate("CLIENT", spiffeUserUri); - X509AuthenticationProvider provider = createProvider(userCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{userCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); - } finally { - SpiffeAuthTestUtil.clearSpiffeSystemProperties(); - } - } - - @Test - public void testSpiffeMultipleSpiffeSansFallsBackToDn() { - // Cert with >1 SPIFFE SAN — extractor throws, caught in getClientId, falls through to URN - // (not configured) then to Subject DN. - SpiffeAuthTestUtil.setSpiffeSystemProperties(); - try { - TestCertificate multiSanCert = new TestCertificate("CLIENT", - Arrays.asList(SPIFFE_V2_URI, "spiffe://prod.lipki/v2/application/another-service/another-service")); - X509AuthenticationProvider provider = createProvider(multiSanCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{multiSanCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); - } finally { - SpiffeAuthTestUtil.clearSpiffeSystemProperties(); - } - } - - @Test - public void testSpiffePrefersSpiffeOverUrnWhenBothPresent() { - // Cert has BOTH a URN-format SAN and a SPIFFE SAN. SPIFFE must win. - String urnSan = "urn:li:servicePrincipal(legacy-app;ei4;i001)"; - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, "^.*urn:li:.*$"); - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, - "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); - System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); - // SPIFFE detection is always on and is tried first, so it wins regardless of URN config. - - try { - TestCertificate mixedCert = new TestCertificate("CLIENT", Arrays.asList(urnSan, SPIFFE_V2_URI)); - X509AuthenticationProvider provider = createProvider(mixedCert); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{mixedCert}; - - assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - // SPIFFE wins — path-after-/v2/, not the URN-derived legacy-app id. - assertEquals("application/espresso-router/espresso-router", - cnxn.getAuthInfo().get(0).getId()); - } finally { - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); - System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); - SpiffeAuthTestUtil.clearSpiffeSystemProperties(); - } - } - protected static class TestPublicKey implements PublicKey { private static final long serialVersionUID = 1L; diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java index 62f0690b2b6..eaf683151d4 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java @@ -32,11 +32,13 @@ import org.junit.Test; /** - * Integration tests for SPIFFE SAN-based client identity extraction. Unlike - * {@link X509AuthTest}, which uses a hand-rolled mock cert, these tests construct REAL - * {@link X509Certificate} instances (BouncyCastle-signed) with SPIFFE URI SANs and run them - * through the full {@link X509AuthenticationProvider#handleAuthentication} path. This exercises - * the JDK's actual SAN parsing, which the mock cert bypasses. + * Integration tests for SPIFFE SAN-based client identity extraction. This is the authoritative + * test suite for SPIFFE certificate validation and principal extraction: every test constructs a + * REAL {@link X509Certificate} (BouncyCastle-signed) with real SPIFFE URI SANs and runs it + * through the full {@link X509AuthenticationProvider#handleAuthentication} path, exercising the + * JDK's actual ASN.1/SAN parsing end-to-end rather than a hand-rolled mock. {@link X509AuthTest} + * retains only the generic (non-SPIFFE) auth/SAN-regex tests, which still use a lightweight fake + * {@code X509Certificate} since they don't need real certificate parsing. */ public class X509SpiffeAuthIntegrationTest extends ZKTestCase { @@ -63,6 +65,47 @@ public void testRealCertWithSpiffeV1WlUriSanIsExtracted() throws Exception { assertEquals("espresso-router", id); } + @Test + public void testRealCertWithSpiffeV1WlUriSanIsExtractedWithoutAnyConfiguration() throws Exception { + // Same "not a feature flag" guarantee as the v2 case, for the v1/wl form: zero system + // properties set, real BouncyCastle-signed cert. + assertNull("Test must start with no clientCertIdType configured", + System.getProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE)); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v1/wl/espresso-router"); + + String id = runAuth(cert); + + assertEquals("espresso-router", id); + } + + @Test + public void testRealCertWithSpiffeV1WlMultiSegmentFallsBackToSubjectDn() throws Exception { + // v1/wl app-name must be a single path segment. A multi-segment value after "wl/" (e.g. + // "a/b") must NOT be accepted as a single app-name principal; falls through to Subject + // DN (URN extraction not configured here). + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v1/wl/a/b"); + + String id = runAuth(cert); + + assertEquals(cert.getSubjectX500Principal().getName(), id); + } + + @Test + public void testRealCertWithSpiffeV1UserIdentityRejectedFallsBackToSubjectDn() throws Exception { + // v1 user-identity is rejected for two independent reasons: user-identity rejection AND + // v1 "user" not being a recognized workload sub-type. Falls through to Subject DN. + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v1/user/alice"); + + String id = runAuth(cert); + + assertEquals(cert.getSubjectX500Principal().getName(), id); + } + @Test public void testRealCertWithSpiffeV1ApplicationUriSanIsExtracted() throws Exception { SpiffeAuthTestUtil.setSpiffeSystemProperties(); @@ -112,6 +155,19 @@ public void testRealCertWithSpiffeV2UriSanIsExtracted() throws Exception { assertEquals("application/espresso-router/espresso-router", id); } + @Test + public void testRealCertWithSpiffeV2WorkloadUriSanIsExtracted() throws Exception { + // LISPIFFE-ID spec §2.B: v2 also supports the "workload/" sub-type, retaining the full + // path (like "application/") rather than stripping the prefix (like v1's "wl/"). + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v2/workload/foo-mp/bar-app/some-tag"); + + String id = runAuth(cert); + + assertEquals("workload/foo-mp/bar-app/some-tag", id); + } + @Test public void testRealCertWithSpiffeV2UriSanIsExtractedWithoutAnyConfiguration() throws Exception { // Core "not a feature flag" guarantee, exercised against a real BouncyCastle-signed @@ -127,6 +183,21 @@ public void testRealCertWithSpiffeV2UriSanIsExtractedWithoutAnyConfiguration() t assertEquals("application/espresso-router/espresso-router", id); } + @Test + public void testRealCertWithSpiffeV2UserIdentityRejectedWithoutAnyConfiguration() throws Exception { + // Rejection of user identities must also hold with zero configuration — a human's + // SPIFFE cert (real, BouncyCastle-signed) must never be promoted to a service principal, + // feature flag or not. + assertNull("Test must start with no clientCertIdType configured", + System.getProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE)); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v2/user/alice"); + + String id = runAuth(cert); + + assertEquals(cert.getSubjectX500Principal().getName(), id); + } + @Test public void testRealCertWithSpiffeUserUriFallsBackToSubjectDn() throws Exception { SpiffeAuthTestUtil.setSpiffeSystemProperties(); @@ -153,6 +224,73 @@ public void testRealCertWithoutSpiffeSanFallsBackToSubjectDn() throws Exception assertEquals(cert.getSubjectX500Principal().getName(), id); } + @Test + public void testRealCertWithNonSpiffeSanFallsBackToUrnWhenUrnConfigured() throws Exception { + // Cert has a URN SAN (not a spiffe:// URI). The always-on SPIFFE check finds no match, + // so it falls through to legacy URN-based SAN extraction (which IS configured here, + // unlike testRealCertWithoutSpiffeSanFallsBackToSubjectDn above). + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, "^.*urn:li:.*$"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, + "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); + try { + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "urn:li:servicePrincipal(espresso-router;ei4;i001)"); + + String id = runAuth(cert); + + assertEquals("servicePrincipal(espresso-router", id); + } finally { + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); + } + } + + @Test + public void testRealCertWithMultipleSpiffeSansFallsBackToSubjectDn() throws Exception { + // Cert with >1 SPIFFE SAN — extractor throws, caught in getClientId, falls through to + // URN (not configured) then to Subject DN. + SpiffeAuthTestUtil.setSpiffeSystemProperties(); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://prod.lipki/v2/application/espresso-router/espresso-router", + "spiffe://prod.lipki/v2/application/another-service/another-service"); + + String id = runAuth(cert); + + assertEquals(cert.getSubjectX500Principal().getName(), id); + } + + @Test + public void testRealCertPrefersSpiffeOverUrnWhenBothPresent() throws Exception { + // Cert has BOTH a URN-format SAN and a SPIFFE SAN. SPIFFE must win, even though URN + // extraction is also configured and would otherwise match. + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, "^.*urn:li:.*$"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, + "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); + try { + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "urn:li:servicePrincipal(legacy-app;ei4;i001)", + "spiffe://prod.lipki/v2/application/espresso-router/espresso-router"); + + String id = runAuth(cert); + + // SPIFFE wins — path-after-/v2/, not the URN-derived legacy-app id. + assertEquals("application/espresso-router/espresso-router", id); + } finally { + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); + } + } + /** * Defense-in-depth: a SAN whose single literal path segment contains {@code %2F} must not * be silently promoted to a multi-segment principal via URI decoding (which could collide From 3b0fc3f6947a4ceb67c41c70da711a6b277e0cb0 Mon Sep 17 00:00:00 2001 From: Aditi Bansal Date: Tue, 15 Sep 2026 07:38:04 +0530 Subject: [PATCH 08/16] Auto-extract urn:li:servicePrincipal(...) SAN to bare app name DEPEND-89163 follow-up: X509AuthenticationUtil.getClientId() now recognizes LinkedIn's legacy Grestin-issued 'urn:li:servicePrincipal(;...)' URI SAN automatically and resolves it to the bare , mirroring how the SPIFFE v1/wl extraction strips its 'wl/' type prefix. This removes the need for every cluster to hand-author a clientCertIdSanExtractRegex just to get parity between legacy Grestin certs and SPIFFE certs during migration (e.g. Kafka's 'servicePrincipal(kafka' vs SPIFFE's 'kafka' znode-name mismatch). The new pattern only runs when clientCertIdType=SAN was never configured, so clusters that already tuned a SAN extractRegex (e.g. one that intentionally keeps the 'servicePrincipal(' prefix, or one that falls back to Subject DN on a misconfigured regex) keep their existing, unchanged behavior. Adds 4 unit tests to X509AuthTest covering: auto-extraction with no config, correct behavior with a sibling servicePrincipalMetadata(...) SAN present, non-interference with an already-configured clientCertIdType=SAN cluster, and the existing no-match fallback to Subject DN. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../server/auth/X509AuthenticationUtil.java | 62 ++++++++++++++ .../apache/zookeeper/test/X509AuthTest.java | 83 +++++++++++++++++++ 2 files changed, 145 insertions(+) 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 f42ed7deba0..09f4893e177 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 @@ -83,6 +83,16 @@ public class X509AuthenticationUtil extends X509Util { private static final Pattern SPIFFE_V1_WORKLOAD_PATH_PATTERN = Pattern.compile("^/v1/(application|airflow)/(.+)$"); + // Matches LinkedIn's legacy Grestin-issued "urn:li:servicePrincipal(;;)" + // URI SAN and captures just . Recognized automatically -- not gated behind + // clientCertIdType/clientCertIdSanExtractRegex config -- so a pre-SPIFFE cert resolves to the + // same bare principal as its SPIFFE v1/wl equivalent (see SPIFFE_V1_WL_PATH_PATTERN), without + // requiring every cluster to hand-author a matching extract regex. Anchored to literal + // "servicePrincipal(" (not e.g. "[a-z]+Principal(") so a sibling SAN on the same cert, such as + // "urn:li:servicePrincipalMetadata(...)", is never mistaken for the identity SAN. + private static final Pattern URN_SERVICE_PRINCIPAL_PATTERN = + Pattern.compile("^urn:li:servicePrincipal\\(([^;)]+)"); + @Override protected String getConfigPrefix() { return X509AuthenticationConfig.SSL_X509_CONFIG_PREFIX; @@ -183,12 +193,36 @@ public static String getClientId(X509Certificate clientCert) { String clientCertIdType = X509AuthenticationConfig.getInstance().getClientCertIdType(); if (clientCertIdType != null && clientCertIdType .equalsIgnoreCase(X509AuthenticationConfig.SUBJECT_ALTERNATIVE_NAME_SHORT)) { + // clientCertIdType=SAN is an explicit operator configuration (match/extract regex tuned + // to their own SAN layout); on failure it has always fallen straight to Subject DN, and + // that contract is preserved as-is here -- the automatic urn:li:servicePrincipal(...) + // fallback below intentionally does NOT get a second try after a configured failure, to + // avoid silently changing the resolved identity for clusters that already tuned this. 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 clientCert.getSubjectX500Principal().getName(); } } + + // DEPEND-89163 follow-up: recognize LinkedIn's legacy urn:li:servicePrincipal(;...) SAN + // automatically, so a pre-SPIFFE Grestin cert resolves to the same bare principal as its + // SPIFFE v1/wl equivalent, without requiring a per-cluster clientCertIdSanExtractRegex. Only + // reached when clientCertIdType=SAN was never configured -- this preserves existing behavior + // for clusters that already rely on a configured extractRegex (e.g. one that intentionally + // keeps the "servicePrincipal(" prefix, or that falls back to DN on a misconfigured regex). + try { + Optional urnServicePrincipalId = + X509AuthenticationUtil.matchAndExtractUrnServicePrincipalSAN(clientCert); + if (urnServicePrincipalId.isPresent()) { + LOG.debug("Extracted URN service-principal identity: {}", urnServicePrincipalId.get()); + return urnServicePrincipalId.get(); + } + } catch (Exception e) { + LOG.warn("Failed to extract URN service-principal identity from SAN. Using Subject DN instead.", e); + } + return clientCert.getSubjectX500Principal().getName(); } @@ -265,6 +299,34 @@ private static Optional matchAndExtractSpiffeSAN(X509Certificate clientC return Optional.empty(); } + /** + * Attempt to extract a client identity from a legacy Grestin-issued + * {@code urn:li:servicePrincipal(;...)} URI SAN. Recognized automatically -- not gated + * behind any operator configuration -- so a pre-SPIFFE cert already carrying this SAN + * resolves to the same bare {@code } principal as its SPIFFE v1/wl equivalent (see + * {@link #matchAndExtractSpiffeSAN}), without requiring a per-cluster + * {@code clientCertIdSanExtractRegex}. + * + *

    Returns {@link Optional#empty()} when no URI SAN begins with literal + * {@code urn:li:servicePrincipal(} -- notably, a sibling SAN such as + * {@code urn:li:servicePrincipalMetadata(...)} does not match, since the pattern requires + * {@code (} to immediately follow {@code servicePrincipal}. Caller falls through to Subject DN. + * + * @throws IllegalArgumentException if multiple URI SANs begin with {@code urn:li:servicePrincipal(} + */ + private static Optional matchAndExtractUrnServicePrincipalSAN(X509Certificate clientCert) + throws CertificateParsingException { + String urn = findSingleMatchingSan(clientCert, 6, URN_SERVICE_PRINCIPAL_PATTERN, "URN service principal"); + if (urn == null) { + return Optional.empty(); + } + Matcher matcher = URN_SERVICE_PRINCIPAL_PATTERN.matcher(urn); + if (matcher.find()) { + return Optional.of(matcher.group(1)); + } + 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). diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java index 8df45a45d48..01533616144 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java @@ -202,6 +202,89 @@ public void testUrnMatchRegexAnchoredToServicePrincipalExtractsCorrectlyWithGres } } + @Test + public void testUrnServicePrincipalAutoExtractedWithoutConfig() { + // A pre-SPIFFE Grestin cert carrying only "urn:li:servicePrincipal(;...)" -- with NO + // clientCertIdType/clientCertIdSanExtractRegex configured at all -- must still resolve to + // the bare , automatically, matching the SPIFFE v1/wl equivalent's bare principal. + String servicePrincipalSan = "urn:li:servicePrincipal(kafka;ei4;i001)"; + TestCertificate grestinCert = new TestCertificate("CLIENT", servicePrincipalSan); + X509AuthenticationProvider provider = createProvider(grestinCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{grestinCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("kafka", cnxn.getAuthInfo().get(0).getId()); + } + + @Test + public void testUrnServicePrincipalAutoExtractionSkipsSiblingMetadataSan() { + // Same two-SAN Grestin-style cert used in the dual-SAN tests above (servicePrincipal + + // servicePrincipalMetadata), but with NO config set. Unlike a hand-authored match regex + // (which can accidentally match both SANs, see testUrnMatchRegexTooBroad... above), the + // built-in pattern is anchored to literal "servicePrincipal(" so it matches exactly one SAN + // and is unaffected by the sibling metadata SAN. + String servicePrincipalSan = "urn:li:servicePrincipal(kafka;ei4;i001)"; + String servicePrincipalMetadataSan = "urn:li:servicePrincipalMetadata(dev;1.0.0)"; + TestCertificate grestinCert = new TestCertificate("CLIENT", + Arrays.asList(servicePrincipalSan, servicePrincipalMetadataSan)); + X509AuthenticationProvider provider = createProvider(grestinCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{grestinCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("kafka", cnxn.getAuthInfo().get(0).getId()); + } + + @Test + public void testUrnServicePrincipalAutoExtractionDoesNotOverrideConfiguredSanExtraction() { + // Backward compatibility: a cluster that already has clientCertIdType=SAN configured (e.g. + // with a hand-authored extractRegex that intentionally keeps the "servicePrincipal(" prefix, + // as in testUrnMatchRegexAnchoredToServicePrincipalExtractsCorrectlyWithGrestinMetadataSan + // above) must keep getting its configured result. The new automatic fallback must only run + // when clientCertIdType=SAN was never configured, or its extraction failed. + String servicePrincipalSan = "urn:li:servicePrincipal(zk-test-client;None;i001)"; + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, + "^.*urn:li:servicePrincipal\\(.*$"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, + "^.*urn:li:([a-z]+Principal\\([^;%:]+)"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); + + try { + TestCertificate grestinCert = new TestCertificate("CLIENT", servicePrincipalSan); + X509AuthenticationProvider provider = createProvider(grestinCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{grestinCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + // Configured extraction still wins -- keeps the "servicePrincipal(" prefix, unchanged from + // pre-existing behavior. NOT "zk-test-client". + assertEquals("servicePrincipal(zk-test-client", cnxn.getAuthInfo().get(0).getId()); + } finally { + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); + X509AuthenticationConfig.reset(); + } + } + + @Test + public void testUrnServicePrincipalAutoExtractionSkippedWhenNoMatchingSan() { + // Sanity check: a cert with no urn:li:servicePrincipal(...) SAN at all (default TEST_SAN_STR) + // and no config set must fall all the way through to Subject DN, unchanged from before. + TestCertificate certWithoutUrnSan = new TestCertificate("CLIENT"); + X509AuthenticationProvider provider = createProvider(certWithoutUrnSan); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{certWithoutUrnSan}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + } + protected static class TestPublicKey implements PublicKey { private static final long serialVersionUID = 1L; From 2404f0634a429979a1d73b47730dfc951775ea40 Mon Sep 17 00:00:00 2001 From: Aditi Bansal Date: Tue, 15 Sep 2026 07:56:51 +0530 Subject: [PATCH 09/16] Make URN extraction comments and examples vendor-neutral Remove company and internal-ticket references from the added comments and use generic certificate fixtures. Clarify that configured SAN extraction failures retain the Subject DN fallback; extraction behavior is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../server/auth/X509AuthenticationUtil.java | 37 ++++------------ .../apache/zookeeper/test/X509AuthTest.java | 42 ++++++------------- 2 files changed, 21 insertions(+), 58 deletions(-) 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 09f4893e177..883db51933d 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 @@ -83,13 +83,7 @@ public class X509AuthenticationUtil extends X509Util { private static final Pattern SPIFFE_V1_WORKLOAD_PATH_PATTERN = Pattern.compile("^/v1/(application|airflow)/(.+)$"); - // Matches LinkedIn's legacy Grestin-issued "urn:li:servicePrincipal(;;)" - // URI SAN and captures just . Recognized automatically -- not gated behind - // clientCertIdType/clientCertIdSanExtractRegex config -- so a pre-SPIFFE cert resolves to the - // same bare principal as its SPIFFE v1/wl equivalent (see SPIFFE_V1_WL_PATH_PATTERN), without - // requiring every cluster to hand-author a matching extract regex. Anchored to literal - // "servicePrincipal(" (not e.g. "[a-z]+Principal(") so a sibling SAN on the same cert, such as - // "urn:li:servicePrincipalMetadata(...)", is never mistaken for the identity SAN. + // Require "(" immediately after "servicePrincipal" so metadata SANs do not match. private static final Pattern URN_SERVICE_PRINCIPAL_PATTERN = Pattern.compile("^urn:li:servicePrincipal\\(([^;)]+)"); @@ -193,11 +187,8 @@ public static String getClientId(X509Certificate clientCert) { String clientCertIdType = X509AuthenticationConfig.getInstance().getClientCertIdType(); if (clientCertIdType != null && clientCertIdType .equalsIgnoreCase(X509AuthenticationConfig.SUBJECT_ALTERNATIVE_NAME_SHORT)) { - // clientCertIdType=SAN is an explicit operator configuration (match/extract regex tuned - // to their own SAN layout); on failure it has always fallen straight to Subject DN, and - // that contract is preserved as-is here -- the automatic urn:li:servicePrincipal(...) - // fallback below intentionally does NOT get a second try after a configured failure, to - // avoid silently changing the resolved identity for clusters that already tuned this. + // Preserve configured extraction and its Subject DN fallback; do not retry with + // automatic URN extraction if the configured regex fails. try { return X509AuthenticationUtil.matchAndExtractSAN(clientCert); } catch (Exception ce) { @@ -206,12 +197,8 @@ public static String getClientId(X509Certificate clientCert) { } } - // DEPEND-89163 follow-up: recognize LinkedIn's legacy urn:li:servicePrincipal(;...) SAN - // automatically, so a pre-SPIFFE Grestin cert resolves to the same bare principal as its - // SPIFFE v1/wl equivalent, without requiring a per-cluster clientCertIdSanExtractRegex. Only - // reached when clientCertIdType=SAN was never configured -- this preserves existing behavior - // for clusters that already rely on a configured extractRegex (e.g. one that intentionally - // keeps the "servicePrincipal(" prefix, or that falls back to DN on a misconfigured regex). + // Without configured SAN extraction, normalize service-principal URNs to the same + // bare application name used by SPIFFE v1/wl identities. try { Optional urnServicePrincipalId = X509AuthenticationUtil.matchAndExtractUrnServicePrincipalSAN(clientCert); @@ -300,19 +287,11 @@ private static Optional matchAndExtractSpiffeSAN(X509Certificate clientC } /** - * Attempt to extract a client identity from a legacy Grestin-issued - * {@code urn:li:servicePrincipal(;...)} URI SAN. Recognized automatically -- not gated - * behind any operator configuration -- so a pre-SPIFFE cert already carrying this SAN - * resolves to the same bare {@code } principal as its SPIFFE v1/wl equivalent (see - * {@link #matchAndExtractSpiffeSAN}), without requiring a per-cluster - * {@code clientCertIdSanExtractRegex}. + * Extract the application name from a {@code urn:li:servicePrincipal(;...)} URI SAN. * - *

    Returns {@link Optional#empty()} when no URI SAN begins with literal - * {@code urn:li:servicePrincipal(} -- notably, a sibling SAN such as - * {@code urn:li:servicePrincipalMetadata(...)} does not match, since the pattern requires - * {@code (} to immediately follow {@code servicePrincipal}. Caller falls through to Subject DN. + * @return the extracted name, or {@link Optional#empty()} if no matching SAN is present * - * @throws IllegalArgumentException if multiple URI SANs begin with {@code urn:li:servicePrincipal(} + * @throws IllegalArgumentException if multiple service-principal SANs match */ private static Optional matchAndExtractUrnServicePrincipalSAN(X509Certificate clientCert) throws CertificateParsingException { diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java index 01533616144..23a7a5a3924 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java @@ -204,14 +204,11 @@ public void testUrnMatchRegexAnchoredToServicePrincipalExtractsCorrectlyWithGres @Test public void testUrnServicePrincipalAutoExtractedWithoutConfig() { - // A pre-SPIFFE Grestin cert carrying only "urn:li:servicePrincipal(;...)" -- with NO - // clientCertIdType/clientCertIdSanExtractRegex configured at all -- must still resolve to - // the bare , automatically, matching the SPIFFE v1/wl equivalent's bare principal. - String servicePrincipalSan = "urn:li:servicePrincipal(kafka;ei4;i001)"; - TestCertificate grestinCert = new TestCertificate("CLIENT", servicePrincipalSan); - X509AuthenticationProvider provider = createProvider(grestinCert); + String servicePrincipalSan = "urn:li:servicePrincipal(kafka;region1;instance1)"; + TestCertificate serviceCert = new TestCertificate("CLIENT", servicePrincipalSan); + X509AuthenticationProvider provider = createProvider(serviceCert); MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{grestinCert}; + cnxn.clientChain = new X509Certificate[]{serviceCert}; assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); assertEquals("kafka", cnxn.getAuthInfo().get(0).getId()); @@ -219,18 +216,13 @@ public void testUrnServicePrincipalAutoExtractedWithoutConfig() { @Test public void testUrnServicePrincipalAutoExtractionSkipsSiblingMetadataSan() { - // Same two-SAN Grestin-style cert used in the dual-SAN tests above (servicePrincipal + - // servicePrincipalMetadata), but with NO config set. Unlike a hand-authored match regex - // (which can accidentally match both SANs, see testUrnMatchRegexTooBroad... above), the - // built-in pattern is anchored to literal "servicePrincipal(" so it matches exactly one SAN - // and is unaffected by the sibling metadata SAN. - String servicePrincipalSan = "urn:li:servicePrincipal(kafka;ei4;i001)"; + String servicePrincipalSan = "urn:li:servicePrincipal(kafka;region1;instance1)"; String servicePrincipalMetadataSan = "urn:li:servicePrincipalMetadata(dev;1.0.0)"; - TestCertificate grestinCert = new TestCertificate("CLIENT", + TestCertificate serviceCert = new TestCertificate("CLIENT", Arrays.asList(servicePrincipalSan, servicePrincipalMetadataSan)); - X509AuthenticationProvider provider = createProvider(grestinCert); + X509AuthenticationProvider provider = createProvider(serviceCert); MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{grestinCert}; + cnxn.clientChain = new X509Certificate[]{serviceCert}; assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); assertEquals("kafka", cnxn.getAuthInfo().get(0).getId()); @@ -238,12 +230,8 @@ public void testUrnServicePrincipalAutoExtractionSkipsSiblingMetadataSan() { @Test public void testUrnServicePrincipalAutoExtractionDoesNotOverrideConfiguredSanExtraction() { - // Backward compatibility: a cluster that already has clientCertIdType=SAN configured (e.g. - // with a hand-authored extractRegex that intentionally keeps the "servicePrincipal(" prefix, - // as in testUrnMatchRegexAnchoredToServicePrincipalExtractsCorrectlyWithGrestinMetadataSan - // above) must keep getting its configured result. The new automatic fallback must only run - // when clientCertIdType=SAN was never configured, or its extraction failed. - String servicePrincipalSan = "urn:li:servicePrincipal(zk-test-client;None;i001)"; + // Automatic URN extraction must not override an explicitly configured identity format. + String servicePrincipalSan = "urn:li:servicePrincipal(zk-test-client;region1;instance1)"; System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, @@ -253,14 +241,12 @@ public void testUrnServicePrincipalAutoExtractionDoesNotOverrideConfiguredSanExt System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); try { - TestCertificate grestinCert = new TestCertificate("CLIENT", servicePrincipalSan); - X509AuthenticationProvider provider = createProvider(grestinCert); + TestCertificate serviceCert = new TestCertificate("CLIENT", servicePrincipalSan); + X509AuthenticationProvider provider = createProvider(serviceCert); MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{grestinCert}; + cnxn.clientChain = new X509Certificate[]{serviceCert}; assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - // Configured extraction still wins -- keeps the "servicePrincipal(" prefix, unchanged from - // pre-existing behavior. NOT "zk-test-client". assertEquals("servicePrincipal(zk-test-client", cnxn.getAuthInfo().get(0).getId()); } finally { System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE); @@ -274,8 +260,6 @@ public void testUrnServicePrincipalAutoExtractionDoesNotOverrideConfiguredSanExt @Test public void testUrnServicePrincipalAutoExtractionSkippedWhenNoMatchingSan() { - // Sanity check: a cert with no urn:li:servicePrincipal(...) SAN at all (default TEST_SAN_STR) - // and no config set must fall all the way through to Subject DN, unchanged from before. TestCertificate certWithoutUrnSan = new TestCertificate("CLIENT"); X509AuthenticationProvider provider = createProvider(certWithoutUrnSan); MockServerCnxn cnxn = new MockServerCnxn(); From 7c02329bb2c0360690c6d8e346978f2be19a48c2 Mon Sep 17 00:00:00 2001 From: Aditi Bansal Date: Tue, 15 Sep 2026 08:53:55 +0530 Subject: [PATCH 10/16] Match typed SPIFFE identities against legacy service-principal mappings Replace automatic certificate-side URN normalization with type-aware domain lookup. Return an immutable certificate-type/client-ID pair while preserving the original SPIFFE, configured SAN and Subject DN strings. Only SPIFFE v1/wl identities may fall back to exact application names parsed from legacy service-principal mapping keys; explicit mappings and full-path matching retain precedence and behavior. Cover typed identities, legacy mapping compatibility, exact-match precedence, principal/type isolation and mapping refresh. Use independent temporary databases for the mapping fixture. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../auth/X509AuthenticationProvider.java | 2 +- .../server/auth/X509AuthenticationUtil.java | 94 +++++----- .../ClientUriDomainMappingHelper.java | 8 + .../groupacl/X509ZNodeGroupAclProvider.java | 6 +- .../ZkClientUriDomainMappingHelper.java | 26 +++ .../ZkClientUriDomainMappingHelperTest.java | 169 +++++++++++++++++- .../apache/zookeeper/test/X509AuthTest.java | 26 ++- .../test/X509SpiffeAuthIntegrationTest.java | 56 ++++++ 8 files changed, 322 insertions(+), 65 deletions(-) diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java index bf0dcf195b9..a9dfd86c5f8 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java @@ -94,7 +94,7 @@ public KeeperException.Code handleAuthentication(ServerCnxn cnxn, byte[] authDat return KeeperException.Code.AUTHFAILED; } - String clientId = X509AuthenticationUtil.getClientId(clientCert); + String clientId = X509AuthenticationUtil.getClientId(clientCert).getId(); if (clientId.equals(System.getProperty(ZOOKEEPER_X509AUTHENTICATIONPROVIDER_SUPERUSER))) { cnxn.addAuthInfo(new Id(X509AuthenticationUtil.SUPERUSER_AUTH_SCHEME, clientId)); 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 883db51933d..776b2bbfa37 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 @@ -24,6 +24,7 @@ import java.security.cert.X509Certificate; import java.util.Collection; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -50,6 +51,32 @@ public class X509AuthenticationUtil extends X509Util { public static final String SUPERUSER_AUTH_SCHEME = "super"; public static final String X509_SCHEME = "x509"; + public enum CertificateType { + SPIFFE_V1_WL, + SPIFFE_V1_WORKLOAD, + SPIFFE_V2, + LEGACY_SAN, + SUBJECT_DN + } + + public static final class ClientIdentity { + private final CertificateType certificateType; + private final String id; + + private ClientIdentity(CertificateType certificateType, String id) { + this.certificateType = Objects.requireNonNull(certificateType); + this.id = Objects.requireNonNull(id); + } + + public CertificateType getCertificateType() { + return certificateType; + } + + public String getId() { + return id; + } + } + // Matches any SPIFFE URI, regardless of trust domain or version. SPIFFE detection is always // active — not gated behind operator config — and relies entirely on the TLS trust manager to // reject certificates from untrusted issuers before this code is ever reached. @@ -83,10 +110,6 @@ public class X509AuthenticationUtil extends X509Util { private static final Pattern SPIFFE_V1_WORKLOAD_PATH_PATTERN = Pattern.compile("^/v1/(application|airflow)/(.+)$"); - // Require "(" immediately after "servicePrincipal" so metadata SANs do not match. - private static final Pattern URN_SERVICE_PRINCIPAL_PATTERN = - Pattern.compile("^urn:li:servicePrincipal\\(([^;)]+)"); - @Override protected String getConfigPrefix() { return X509AuthenticationConfig.SSL_X509_CONFIG_PREFIX; @@ -164,20 +187,20 @@ public static X509TrustManager createTrustManager(ZKConfig config) { * * @param clientCert Authenticated X509Certificate associated with the * remote host. - * @return Identifier string to be associated with the client. + * @return Certificate type and client identifier to be associated with the client. * The clientId can be any string matched and extracted using regex from Subject Distinguished Name or * Subject Alternative Name from x509 certificate. * The clientId string is intended to be an URI for client and map the client to certain domain. */ - public static String getClientId(X509Certificate clientCert) { + public static ClientIdentity getClientId(X509Certificate clientCert) { // SPIFFE identity extraction always runs, regardless of clientCertIdType configuration — // it is not a feature flag. Any URI SAN beginning with "spiffe://" is treated as a // candidate; trust in the issuing CA/trust-domain is established upstream by the TLS // handshake's trust manager, not by this method. try { - Optional spiffeId = X509AuthenticationUtil.matchAndExtractSpiffeSAN(clientCert); + Optional spiffeId = X509AuthenticationUtil.matchAndExtractSpiffeSAN(clientCert); if (spiffeId.isPresent()) { - LOG.debug("Extracted SPIFFE identity: {}", spiffeId.get()); + LOG.debug("Extracted SPIFFE identity: {}", spiffeId.get().getId()); return spiffeId.get(); } } catch (Exception e) { @@ -187,30 +210,14 @@ public static String getClientId(X509Certificate clientCert) { String clientCertIdType = X509AuthenticationConfig.getInstance().getClientCertIdType(); if (clientCertIdType != null && clientCertIdType .equalsIgnoreCase(X509AuthenticationConfig.SUBJECT_ALTERNATIVE_NAME_SHORT)) { - // Preserve configured extraction and its Subject DN fallback; do not retry with - // automatic URN extraction if the configured regex fails. try { - return X509AuthenticationUtil.matchAndExtractSAN(clientCert); + return new ClientIdentity(CertificateType.LEGACY_SAN, + X509AuthenticationUtil.matchAndExtractSAN(clientCert)); } catch (Exception ce) { LOG.warn("Failed to match and extract a client ID from SAN. Using Subject DN instead.", ce); - return clientCert.getSubjectX500Principal().getName(); - } - } - - // Without configured SAN extraction, normalize service-principal URNs to the same - // bare application name used by SPIFFE v1/wl identities. - try { - Optional urnServicePrincipalId = - X509AuthenticationUtil.matchAndExtractUrnServicePrincipalSAN(clientCert); - if (urnServicePrincipalId.isPresent()) { - LOG.debug("Extracted URN service-principal identity: {}", urnServicePrincipalId.get()); - return urnServicePrincipalId.get(); } - } catch (Exception e) { - LOG.warn("Failed to extract URN service-principal identity from SAN. Using Subject DN instead.", e); } - - return clientCert.getSubjectX500Principal().getName(); + return new ClientIdentity(CertificateType.SUBJECT_DN, clientCert.getSubjectX500Principal().getName()); } /** @@ -239,7 +246,7 @@ public static String getClientId(X509Certificate clientCert) { * * @throws IllegalArgumentException if multiple URI SANs begin with {@code spiffe://} */ - private static Optional matchAndExtractSpiffeSAN(X509Certificate clientCert) + private static Optional matchAndExtractSpiffeSAN(X509Certificate clientCert) throws CertificateParsingException { String spiffeUri = findSingleMatchingSan(clientCert, 6, SPIFFE_URI_PATTERN, "SPIFFE"); if (spiffeUri == null) { @@ -271,41 +278,22 @@ private static Optional matchAndExtractSpiffeSAN(X509Certificate clientC } Matcher v2Matcher = SPIFFE_V2_PATH_PATTERN.matcher(path); if (v2Matcher.matches()) { - return Optional.of(v2Matcher.group(1)); + return Optional.of(new ClientIdentity(CertificateType.SPIFFE_V2, v2Matcher.group(1))); } Matcher v1WlMatcher = SPIFFE_V1_WL_PATH_PATTERN.matcher(path); if (v1WlMatcher.matches()) { - return Optional.of(v1WlMatcher.group(1)); + return Optional.of(new ClientIdentity(CertificateType.SPIFFE_V1_WL, v1WlMatcher.group(1))); } Matcher v1WorkloadMatcher = SPIFFE_V1_WORKLOAD_PATH_PATTERN.matcher(path); if (v1WorkloadMatcher.matches()) { - return Optional.of(v1WorkloadMatcher.group(1) + "/" + v1WorkloadMatcher.group(2)); + return Optional.of(new ClientIdentity(CertificateType.SPIFFE_V1_WORKLOAD, + v1WorkloadMatcher.group(1) + "/" + v1WorkloadMatcher.group(2))); } LOG.debug("SPIFFE URI '{}' is not a v1/wl, v1/application, v1/airflow, or v2 identity; " + "falling through to URN/DN extraction.", spiffeUri); return Optional.empty(); } - /** - * Extract the application name from a {@code urn:li:servicePrincipal(;...)} URI SAN. - * - * @return the extracted name, or {@link Optional#empty()} if no matching SAN is present - * - * @throws IllegalArgumentException if multiple service-principal SANs match - */ - private static Optional matchAndExtractUrnServicePrincipalSAN(X509Certificate clientCert) - throws CertificateParsingException { - String urn = findSingleMatchingSan(clientCert, 6, URN_SERVICE_PRINCIPAL_PATTERN, "URN service principal"); - if (urn == null) { - return Optional.empty(); - } - Matcher matcher = URN_SERVICE_PRINCIPAL_PATTERN.matcher(urn); - if (matcher.find()) { - return Optional.of(matcher.group(1)); - } - 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). @@ -356,13 +344,13 @@ private static String applyExtractRegex(Pattern extractPattern, String value, in * Extract the authenticated client Id from the specified server connection object. * @param cnxn Server connection object that contains the certificate. * @param trustManager X509 TrustManager for authentication. - * @return Identifier string to be associated with the client. + * @return Certificate type and client identifier to be associated with the client. * The clientId can be any string matched and extracted using regex from Subject Distinguished Name or * Subject Alternative Name from x509 certificate. * The clientId string is intended to be an URI for client and map the client to certain domain. * @throws KeeperException.AuthFailedException Failed to authenticate the client certificate */ - public static String getClientId(ServerCnxn cnxn, X509TrustManager trustManager) + public static ClientIdentity getClientId(ServerCnxn cnxn, X509TrustManager trustManager) throws KeeperException.AuthFailedException { X509Certificate clientCert = X509AuthenticationUtil.getAuthenticatedClientCert(cnxn, trustManager); return X509AuthenticationUtil.getClientId(clientCert); diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/znode/groupacl/ClientUriDomainMappingHelper.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/znode/groupacl/ClientUriDomainMappingHelper.java index b74b27c5e3a..db23bc4fef7 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/znode/groupacl/ClientUriDomainMappingHelper.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/znode/groupacl/ClientUriDomainMappingHelper.java @@ -20,6 +20,7 @@ import java.util.Set; import org.apache.zookeeper.server.ServerCnxn; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.CertificateType; /** * Helper class for looking up the domain name for the client connection. It uses the client's @@ -43,6 +44,13 @@ public interface ClientUriDomainMappingHelper { */ Set getDomains(String clientUri); + /** + * Resolve domains using the certificate type to scope compatibility matching. + */ + default Set getDomains(CertificateType certificateType, String clientUri) { + return getDomains(clientUri); + } + /** * Update the domain-based AuthInfo for the specified connection. * @param cnxn Connection to update 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 688a99479e1..76b815eed4d 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 @@ -33,6 +33,7 @@ import org.apache.zookeeper.server.auth.ServerAuthenticationProvider; import org.apache.zookeeper.server.auth.X509AuthenticationConfig; import org.apache.zookeeper.server.auth.X509AuthenticationUtil; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -169,8 +170,9 @@ private ClientUriDomainMappingHelper getUriDomainMappingHelper(ZooKeeperServer z // 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, helper.getDomains(clientId)); + ClientIdentity identity = X509AuthenticationUtil.getClientId(cnxn, trustManager); + assignAuthInfo(cnxn, identity.getId(), + helper.getDomains(identity.getCertificateType(), identity.getId())); } 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 c282097bb7c..2460dc78d9e 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 @@ -26,6 +26,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.apache.zookeeper.KeeperException; import org.apache.zookeeper.WatchedEvent; import org.apache.zookeeper.ZooDefs; @@ -34,6 +36,7 @@ import org.apache.zookeeper.server.ServerCnxnFactory; import org.apache.zookeeper.server.ZooKeeperServer; import org.apache.zookeeper.server.auth.X509AuthenticationConfig; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.CertificateType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -75,6 +78,10 @@ public class ZkClientUriDomainMappingHelper implements ClientUriDomainMappingHel private static final Logger LOG = LoggerFactory.getLogger(ZkClientUriDomainMappingHelper.class); + // Accept the legacy truncated name, a closed principal, or a complete service-principal URN. + private static final Pattern LEGACY_SERVICE_PRINCIPAL_PATTERN = + Pattern.compile("^(?:urn:li:)?servicePrincipal\\(([^();/]+)(?:\\)|;[^()/]*\\))?$"); + private final ZooKeeperServer zks; private final String rootPath; @@ -199,6 +206,15 @@ void setClientUriToDomainNames(Map> mapping) { */ @Override public Set getDomains(String clientUri) { + return getDomains(null, clientUri); + } + + /** + * After exact lookup, only SPIFFE v1/wl identities may match the application name in + * a legacy service-principal znode. Other types retain the existing exact/prefix lookup. + */ + @Override + public Set getDomains(CertificateType certificateType, String clientUri) { if (clientUri == null) { return Collections.emptySet(); } @@ -210,6 +226,16 @@ public Set getDomains(String clientUri) { if (exact != null) { return exact; } + if (certificateType == CertificateType.SPIFFE_V1_WL) { + Set domains = new HashSet<>(); + for (Map.Entry> entry : map.entrySet()) { + Matcher matcher = LEGACY_SERVICE_PRINCIPAL_PATTERN.matcher(entry.getKey()); + if (matcher.matches() && clientUri.equals(matcher.group(1))) { + domains.addAll(entry.getValue()); + } + } + return domains.isEmpty() ? Collections.emptySet() : domains; + } if (clientUri.indexOf('/') < 0) { return Collections.emptySet(); } 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 bc3997dae2a..102edb98724 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 @@ -18,6 +18,7 @@ package org.apache.zookeeper.server.auth.znode.groupacl; +import java.io.File; import java.io.IOException; import java.security.cert.X509Certificate; import java.util.Arrays; @@ -34,11 +35,14 @@ import org.apache.zookeeper.ZooDefs; import org.apache.zookeeper.ZooKeeper; import org.apache.zookeeper.common.SpiffeAuthTestUtil; +import org.apache.zookeeper.data.Id; 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.auth.X509AuthenticationConfig; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.CertificateType; import org.apache.zookeeper.server.watch.WatchesReport; import org.apache.zookeeper.test.ClientBase; import org.junit.After; @@ -79,7 +83,9 @@ public class ZkClientUriDomainMappingHelperTest extends ZKTestCase { 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" + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp-grant/application/helix-core", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/servicePrincipal(kafka" }; private ZooKeeperServer zookeeperServer; @@ -94,7 +100,8 @@ public static void registerBouncyCastle() { @Before public void setUp() throws IOException, InterruptedException, KeeperException { LOG.info("Starting Zk..."); - zookeeperServer = new ZooKeeperServer(testBaseDir, testBaseDir, 3000); + File dataDir = ClientBase.createTmpDir(); + zookeeperServer = new ZooKeeperServer(dataDir, dataDir, 3000); final int PORT = Integer.parseInt(HOSTPORT.split(":")[1]); serverCnxnFactory = ServerCnxnFactory.createFactory(PORT, -1); serverCnxnFactory.startup(zookeeperServer); @@ -333,6 +340,75 @@ public void testA4_SpiffeCertResolvesViaPrefixWalkUpToDomainAuthInfo() throws Ex } } + @Test + public void testA5_SpiffeWorkloadUsesLegacyMappingAndObservesUpdates() throws Exception { + for (String path : new String[] { + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH, + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/servicePrincipal(kafka" + }) { + zookeeperClientConnection.create(path, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + } + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://example.org/v1/wl/kafka"); + X509ZNodeGroupAclProvider provider = new X509ZNodeGroupAclProvider( + new SpiffeAuthTestUtil.AcceptAllTrustManager(), new SpiffeAuthTestUtil.NoopKeyManager()); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{cert}; + ServerAuthenticationProvider.ServerObjs serverObjs = + new ServerAuthenticationProvider.ServerObjs(zookeeperServer, cnxn); + + Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); + Assert.assertTrue(cnxn.getAuthInfo().contains(new Id("x509", "broker-access"))); + + String mappingPath = CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/servicePrincipal(kafka"; + zookeeperClientConnection.delete(mappingPath, -1); + Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); + Assert.assertFalse(cnxn.getAuthInfo().contains(new Id("x509", "broker-access"))); + Assert.assertTrue(cnxn.getAuthInfo().contains(new Id("x509", "kafka"))); + + zookeeperClientConnection.create(mappingPath, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); + Assert.assertTrue(cnxn.getAuthInfo().contains(new Id("x509", "broker-access"))); + } + + @Test + public void testA6_LegacySanStillUsesOriginalMappingKey() throws Exception { + for (String path : new String[] { + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH, + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/servicePrincipal(kafka" + }) { + zookeeperClientConnection.create(path, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + } + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, + "^urn:li:servicePrincipal\\("); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, + "^urn:li:([a-z]+Principal\\([^;%:]+)"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); + X509AuthenticationConfig.reset(); + try { + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "urn:li:servicePrincipal(kafka;region1;instance1)"); + X509ZNodeGroupAclProvider provider = new X509ZNodeGroupAclProvider( + new SpiffeAuthTestUtil.AcceptAllTrustManager(), new SpiffeAuthTestUtil.NoopKeyManager()); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{cert}; + + Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication( + new ServerAuthenticationProvider.ServerObjs(zookeeperServer, cnxn), null)); + Assert.assertTrue(cnxn.getAuthInfo().contains(new Id("x509", "broker-access"))); + } finally { + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX); + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX); + SpiffeAuthTestUtil.clearSpiffeSystemProperties(); + } + } + @Test /** * Make sure the watcher installed while instantiate ZkClientUriDomainMappingHelper does not break @@ -412,6 +488,95 @@ public void testC_GetDomainsNullClientUri() { Assert.assertEquals(Collections.emptySet(), helper.getDomains(null)); } + @Test + public void testD_SpiffeWorkloadMatchesLegacyServicePrincipalNames() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + Map> mapping = new HashMap<>(); + mapping.put("servicePrincipal(kafka", Collections.singleton("truncated-domain")); + mapping.put("servicePrincipal(kafka)", Collections.singleton("closed-domain")); + mapping.put("urn:li:servicePrincipal(kafka;region1;instance1)", Collections.singleton("urn-domain")); + setMapping(helper, mapping); + + Assert.assertEquals(new HashSet<>(Arrays.asList("truncated-domain", "closed-domain", "urn-domain")), + helper.getDomains(CertificateType.SPIFFE_V1_WL, "kafka")); + Assert.assertEquals(Collections.singleton("truncated-domain"), + helper.getDomains(CertificateType.LEGACY_SAN, "servicePrincipal(kafka")); + Assert.assertEquals(Collections.singleton("urn-domain"), + helper.getDomains("urn:li:servicePrincipal(kafka;region1;instance1)")); + } + + @Test + public void testD_LegacyAliasesRequireSpiffeWorkloadType() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + setMapping(helper, Collections.singletonMap("servicePrincipal(kafka", Collections.singleton("broker-access"))); + + for (CertificateType type : CertificateType.values()) { + if (type != CertificateType.SPIFFE_V1_WL) { + Assert.assertEquals(type.name(), Collections.emptySet(), helper.getDomains(type, "kafka")); + } + } + Assert.assertEquals(Collections.emptySet(), helper.getDomains("kafka")); + Assert.assertEquals(Collections.emptySet(), helper.getDomains(CertificateType.SPIFFE_V1_WL, null)); + } + + @Test + public void testD_ExactIdentityMappingOverridesLegacyAlias() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + Map> mapping = new HashMap<>(); + mapping.put("kafka", Collections.singleton("explicit-domain")); + mapping.put("servicePrincipal(kafka", Collections.singleton("legacy-domain")); + setMapping(helper, mapping); + + Assert.assertEquals(Collections.singleton("explicit-domain"), + helper.getDomains(CertificateType.SPIFFE_V1_WL, "kafka")); + } + + @Test + public void testD_TypedMultiSegmentLookupRetainsPrefixMatching() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + Map> mapping = new HashMap<>(); + mapping.put("servicePrincipal(kafka", Collections.singleton("legacy-domain")); + mapping.put("application/example-mp", Collections.singleton("path-domain")); + setMapping(helper, mapping); + + for (CertificateType type : Arrays.asList( + CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { + Assert.assertEquals(Collections.singleton("path-domain"), + helper.getDomains(type, "application/example-mp/kafka")); + Assert.assertEquals(Collections.emptySet(), + helper.getDomains(type, "application/unrelated-mp/kafka")); + Assert.assertEquals(Collections.emptySet(), helper.getDomains(type, "group/kafka")); + } + } + + @Test + public void testD_OtherPrincipalKindsAndMalformedNamesAreNotAliases() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + Map> mapping = new HashMap<>(); + for (String name : Arrays.asList( + "userPrincipal(kafka", + "groupPrincipal(kafka", + "servicePrincipalMetadata(kafka)", + "urn:li:userPrincipal(kafka;region1;instance1)", + "urn:li:groupPrincipal(kafka;region1;instance1)", + "urn:li:servicePrincipalMetadata(kafka;region1;instance1)", + "servicePrincipal(kafka-extra", + "servicePrincipal(Kafka", + "servicePrincipal(%6bafka", + "servicePrincipal(kafka)extra", + "servicePrincipal(kafka;region1;instance1", + "nested/servicePrincipal(kafka")) { + mapping.put(name, Collections.singleton("unrelated-domain")); + } + setMapping(helper, mapping); + + Assert.assertEquals(Collections.emptySet(), helper.getDomains(CertificateType.SPIFFE_V1_WL, "kafka")); + Assert.assertEquals(Collections.singleton("unrelated-domain"), + helper.getDomains(CertificateType.LEGACY_SAN, "userPrincipal(kafka")); + Assert.assertEquals(Collections.singleton("unrelated-domain"), + helper.getDomains(CertificateType.LEGACY_SAN, "groupPrincipal(kafka")); + } + private static void setMapping(ZkClientUriDomainMappingHelper helper, Map> mapping) { helper.setClientUriToDomainNames(mapping); diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java index 23a7a5a3924..fec122fedf4 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509AuthTest.java @@ -49,6 +49,9 @@ import org.apache.zookeeper.server.MockServerCnxn; import org.apache.zookeeper.server.auth.X509AuthenticationConfig; import org.apache.zookeeper.server.auth.X509AuthenticationProvider; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.CertificateType; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; import org.junit.Before; import org.junit.Test; @@ -203,7 +206,7 @@ public void testUrnMatchRegexAnchoredToServicePrincipalExtractsCorrectlyWithGres } @Test - public void testUrnServicePrincipalAutoExtractedWithoutConfig() { + public void testUrnWithoutSanConfigurationKeepsSubjectDn() { String servicePrincipalSan = "urn:li:servicePrincipal(kafka;region1;instance1)"; TestCertificate serviceCert = new TestCertificate("CLIENT", servicePrincipalSan); X509AuthenticationProvider provider = createProvider(serviceCert); @@ -211,11 +214,14 @@ public void testUrnServicePrincipalAutoExtractedWithoutConfig() { cnxn.clientChain = new X509Certificate[]{serviceCert}; assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("kafka", cnxn.getAuthInfo().get(0).getId()); + assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + ClientIdentity identity = X509AuthenticationUtil.getClientId(serviceCert); + assertEquals(CertificateType.SUBJECT_DN, identity.getCertificateType()); + assertEquals("CN=CLIENT", identity.getId()); } @Test - public void testUrnServicePrincipalAutoExtractionSkipsSiblingMetadataSan() { + public void testUrnWithMetadataWithoutSanConfigurationKeepsSubjectDn() { String servicePrincipalSan = "urn:li:servicePrincipal(kafka;region1;instance1)"; String servicePrincipalMetadataSan = "urn:li:servicePrincipalMetadata(dev;1.0.0)"; TestCertificate serviceCert = new TestCertificate("CLIENT", @@ -225,12 +231,13 @@ public void testUrnServicePrincipalAutoExtractionSkipsSiblingMetadataSan() { cnxn.clientChain = new X509Certificate[]{serviceCert}; assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); - assertEquals("kafka", cnxn.getAuthInfo().get(0).getId()); + assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + assertEquals(CertificateType.SUBJECT_DN, + X509AuthenticationUtil.getClientId(serviceCert).getCertificateType()); } @Test - public void testUrnServicePrincipalAutoExtractionDoesNotOverrideConfiguredSanExtraction() { - // Automatic URN extraction must not override an explicitly configured identity format. + public void testClientIdentityPreservesConfiguredSanExtraction() { String servicePrincipalSan = "urn:li:servicePrincipal(zk-test-client;region1;instance1)"; System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); @@ -248,6 +255,9 @@ public void testUrnServicePrincipalAutoExtractionDoesNotOverrideConfiguredSanExt assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); assertEquals("servicePrincipal(zk-test-client", cnxn.getAuthInfo().get(0).getId()); + ClientIdentity identity = X509AuthenticationUtil.getClientId(serviceCert); + assertEquals(CertificateType.LEGACY_SAN, identity.getCertificateType()); + assertEquals("servicePrincipal(zk-test-client", identity.getId()); } finally { System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE); System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); @@ -259,7 +269,7 @@ public void testUrnServicePrincipalAutoExtractionDoesNotOverrideConfiguredSanExt } @Test - public void testUrnServicePrincipalAutoExtractionSkippedWhenNoMatchingSan() { + public void testClientIdentityPreservesSubjectDn() { TestCertificate certWithoutUrnSan = new TestCertificate("CLIENT"); X509AuthenticationProvider provider = createProvider(certWithoutUrnSan); MockServerCnxn cnxn = new MockServerCnxn(); @@ -267,6 +277,8 @@ public void testUrnServicePrincipalAutoExtractionSkippedWhenNoMatchingSan() { assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + assertEquals(CertificateType.SUBJECT_DN, + X509AuthenticationUtil.getClientId(certWithoutUrnSan).getCertificateType()); } protected static class TestPublicKey implements PublicKey { diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java index eaf683151d4..4f653ab8be1 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java @@ -27,6 +27,9 @@ import org.apache.zookeeper.server.MockServerCnxn; import org.apache.zookeeper.server.auth.X509AuthenticationConfig; import org.apache.zookeeper.server.auth.X509AuthenticationProvider; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.CertificateType; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; import org.junit.After; import org.junit.BeforeClass; import org.junit.Test; @@ -52,6 +55,55 @@ public void tearDown() { SpiffeAuthTestUtil.clearSpiffeSystemProperties(); } + @Test + public void testCertificateTypesPreserveOriginalClientIds() throws Exception { + String[] paths = { + "/v1/wl/kafka", + "/v1/application/example-mp/kafka", + "/v1/airflow/example-dag", + "/v2/application/example-mp/kafka", + "/v2/kafka", + "/v2/group/kafka", + "/v1/user/kafka", + "/v2/user/kafka", + "/v1/wl/kafka/extra", + "/v2/%75ser/kafka" + }; + CertificateType[] types = { + CertificateType.SPIFFE_V1_WL, + CertificateType.SPIFFE_V1_WORKLOAD, + CertificateType.SPIFFE_V1_WORKLOAD, + CertificateType.SPIFFE_V2, + CertificateType.SPIFFE_V2, + CertificateType.SPIFFE_V2, + CertificateType.SUBJECT_DN, + CertificateType.SUBJECT_DN, + CertificateType.SUBJECT_DN, + CertificateType.SUBJECT_DN + }; + String[] ids = { + "kafka", + "application/example-mp/kafka", + "airflow/example-dag", + "application/example-mp/kafka", + "kafka", + "group/kafka", + "CN=test-client", + "CN=test-client", + "CN=test-client", + "CN=test-client" + }; + for (int i = 0; i < paths.length; i++) { + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( + "spiffe://example.org" + paths[i]); + ClientIdentity identity = X509AuthenticationUtil.getClientId(cert); + + assertEquals(paths[i], types[i], identity.getCertificateType()); + assertEquals(paths[i], ids[i], identity.getId()); + assertEquals(paths[i], ids[i], runAuth(cert)); + } + } + @Test public void testRealCertWithSpiffeV1WlUriSanIsExtracted() throws Exception { SpiffeAuthTestUtil.setSpiffeSystemProperties(); @@ -242,6 +294,8 @@ public void testRealCertWithNonSpiffeSanFallsBackToUrnWhenUrnConfigured() throws String id = runAuth(cert); assertEquals("servicePrincipal(espresso-router", id); + assertEquals(CertificateType.LEGACY_SAN, + X509AuthenticationUtil.getClientId(cert).getCertificateType()); } finally { System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); @@ -283,6 +337,8 @@ public void testRealCertPrefersSpiffeOverUrnWhenBothPresent() throws Exception { // SPIFFE wins — path-after-/v2/, not the URN-derived legacy-app id. assertEquals("application/espresso-router/espresso-router", id); + assertEquals(CertificateType.SPIFFE_V2, + X509AuthenticationUtil.getClientId(cert).getCertificateType()); } finally { System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE); System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX); From e4a43303bf05da2b076bec6a2bc6a45d6e9ee205 Mon Sep 17 00:00:00 2001 From: Aditi Bansal Date: Tue, 15 Sep 2026 09:24:28 +0530 Subject: [PATCH 11/16] Extend service-principal parity to direct znode ACLs Retain the authenticated certificate-type/client-ID pair on each connection and forward connection context through a backward-compatible authentication-provider matcher overload. Share legacy service-principal parsing between domain lookup and direct ACL checks. Allow SPIFFE v1/wl identities to match legacy service-principal ACLs and typed legacy service principals to match bare application ACLs. Bind compatibility to the original authenticated ID so mapped domains, other principal kinds and v2 paths are not reinterpreted. Preserve AuthInfo, creator ACLs, permission checks and exact superuser handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../apache/zookeeper/server/ServerCnxn.java | 12 + .../server/auth/AuthenticationProvider.java | 8 + .../auth/LegacyServicePrincipalMatcher.java | 62 ++++ .../auth/WrappedAuthenticationProvider.java | 5 +- .../auth/X509AuthenticationProvider.java | 12 +- .../groupacl/X509ZNodeGroupAclProvider.java | 8 +- .../ZkClientUriDomainMappingHelper.java | 10 +- .../server/auth/X509DirectAclTest.java | 284 ++++++++++++++++++ 8 files changed, 389 insertions(+), 12 deletions(-) create mode 100644 zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java create mode 100644 zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/ServerCnxn.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/ServerCnxn.java index 46661a1964c..11359e9b7b7 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/ServerCnxn.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/ServerCnxn.java @@ -46,6 +46,7 @@ import org.apache.zookeeper.metrics.Counter; import org.apache.zookeeper.proto.ReplyHeader; import org.apache.zookeeper.proto.RequestHeader; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -62,6 +63,9 @@ public abstract class ServerCnxn implements Stats, Watcher { private Set authInfo = Collections.newSetFromMap(new ConcurrentHashMap()); + // Retain authenticated type information without reparsing certificates during ACL checks. + private volatile ClientIdentity x509ClientIdentity; + private static final byte[] fourBytes = new byte[4]; /** @@ -285,6 +289,14 @@ public boolean removeAuthInfo(Id id) { return authInfo.remove(id); } + public ClientIdentity getX509ClientIdentity() { + return x509ClientIdentity; + } + + public void setX509ClientIdentity(ClientIdentity identity) { + x509ClientIdentity = identity; + } + abstract void sendBuffer(ByteBuffer... buffers); abstract void enableRecv(); diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/AuthenticationProvider.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/AuthenticationProvider.java index 179eac8dfbf..c1ab5e4099a 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/AuthenticationProvider.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/AuthenticationProvider.java @@ -62,6 +62,14 @@ public interface AuthenticationProvider { */ boolean matches(String id, String aclExpr); + /** + * Connection-aware matching for providers that need authenticated connection context. + * Existing providers retain their string-only matching behavior. + */ + default boolean matches(ServerCnxn cnxn, String id, String aclExpr) { + return matches(id, aclExpr); + } + /** * This method is used to check if the authentication done by this provider * should be used to identify the creator of a node. Some ids such as hosts diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java new file mode 100644 index 00000000000..f51b4e7fac6 --- /dev/null +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java @@ -0,0 +1,62 @@ +/* + * 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.server.auth; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.zookeeper.server.ServerCnxn; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.CertificateType; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; + +/** + * Compatibility matching shared by direct X509 ACLs and URI-domain mappings. + */ +public final class LegacyServicePrincipalMatcher { + private static final Pattern LEGACY_SERVICE_PRINCIPAL_PATTERN = + Pattern.compile("^(?:urn:li:)?servicePrincipal\\(([^();/]+)(?:\\)|;[^()/]*\\))?$"); + + private LegacyServicePrincipalMatcher() { + } + + public static boolean matches(CertificateType certificateType, String clientId, String legacyPrincipal) { + return certificateType == CertificateType.SPIFFE_V1_WL + && clientId != null && clientId.equals(getApplicationName(legacyPrincipal)); + } + + public static boolean matchesAuthenticatedClient(ServerCnxn cnxn, String authenticatedId, String aclId) { + boolean workloadToLegacy = authenticatedId != null && authenticatedId.equals(getApplicationName(aclId)); + boolean legacyToWorkload = aclId != null && aclId.equals(getApplicationName(authenticatedId)); + if (cnxn == null || (!workloadToLegacy && !legacyToWorkload)) { + return false; + } + // Bind the candidate AuthInfo ID to the authenticated certificate identity, not a mapped domain. + ClientIdentity identity = cnxn.getX509ClientIdentity(); + return identity != null && identity.getId().equals(authenticatedId) + && ((identity.getCertificateType() == CertificateType.SPIFFE_V1_WL && workloadToLegacy) + || (identity.getCertificateType() == CertificateType.LEGACY_SAN && legacyToWorkload)); + } + + private static String getApplicationName(String legacyPrincipal) { + if (legacyPrincipal == null) { + return null; + } + Matcher matcher = LEGACY_SERVICE_PRINCIPAL_PATTERN.matcher(legacyPrincipal); + return matcher.matches() ? matcher.group(1) : null; + } +} diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/WrappedAuthenticationProvider.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/WrappedAuthenticationProvider.java index 65dc4376bb0..203b22f8c42 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/WrappedAuthenticationProvider.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/WrappedAuthenticationProvider.java @@ -55,11 +55,12 @@ public KeeperException.Code handleAuthentication(ServerObjs serverObjs, byte[] a /** * {@inheritDoc} * - * forwards to older method {@link #matches(String, String)} + * forwards connection context while preserving legacy providers' default behavior */ @Override public boolean matches(ServerObjs serverObjs, MatchValues matchValues) { - return implementation.matches(matchValues.getId(), matchValues.getAclExpr()); + ServerCnxn cnxn = serverObjs == null ? null : serverObjs.getCnxn(); + return implementation.matches(cnxn, matchValues.getId(), matchValues.getAclExpr()); } @Override diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java index a9dfd86c5f8..6b90e977d23 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java @@ -28,6 +28,7 @@ import org.apache.zookeeper.common.ZKConfig; import org.apache.zookeeper.data.Id; import org.apache.zookeeper.server.ServerCnxn; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -91,10 +92,13 @@ public KeeperException.Code handleAuthentication(ServerCnxn cnxn, byte[] authDat try { clientCert = X509AuthenticationUtil.getAuthenticatedClientCert(cnxn, trustManager); } catch (KeeperException.AuthFailedException e) { + cnxn.setX509ClientIdentity(null); return KeeperException.Code.AUTHFAILED; } - String clientId = X509AuthenticationUtil.getClientId(clientCert).getId(); + ClientIdentity identity = X509AuthenticationUtil.getClientId(clientCert); + cnxn.setX509ClientIdentity(identity); + String clientId = identity.getId(); if (clientId.equals(System.getProperty(ZOOKEEPER_X509AUTHENTICATIONPROVIDER_SUPERUSER))) { cnxn.addAuthInfo(new Id(X509AuthenticationUtil.SUPERUSER_AUTH_SCHEME, clientId)); @@ -118,6 +122,12 @@ public boolean matches(String id, String aclExpr) { return id.equals(aclExpr); } + @Override + public boolean matches(ServerCnxn cnxn, String id, String aclExpr) { + return matches(id, aclExpr) + || LegacyServicePrincipalMatcher.matchesAuthenticatedClient(cnxn, id, aclExpr); + } + @Override public boolean isAuthenticated() { return true; 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 76b815eed4d..bbd5489a83d 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 @@ -30,6 +30,7 @@ import org.apache.zookeeper.data.Id; import org.apache.zookeeper.server.ServerCnxn; import org.apache.zookeeper.server.ZooKeeperServer; +import org.apache.zookeeper.server.auth.LegacyServicePrincipalMatcher; import org.apache.zookeeper.server.auth.ServerAuthenticationProvider; import org.apache.zookeeper.server.auth.X509AuthenticationConfig; import org.apache.zookeeper.server.auth.X509AuthenticationUtil; @@ -98,6 +99,7 @@ public KeeperException.Code handleAuthentication(ServerObjs serverObjs, byte[] a try { X509AuthenticationUtil.getAuthenticatedClientCert(cnxn, trustManager); } catch (KeeperException.AuthFailedException e) { + cnxn.setX509ClientIdentity(null); return KeeperException.Code.AUTHFAILED; } catch (Exception e) { // Failed to extract clientId from certificate @@ -122,7 +124,9 @@ public KeeperException.Code handleAuthentication(ServerObjs serverObjs, byte[] a public boolean matches(ServerObjs serverObjs, MatchValues matchValues) { // Not checking for super user here because the check is already covered // in checkAcl() in ZookeeperServer.class - return matchValues.getId().equals(matchValues.getAclExpr()); + return matchValues.getId().equals(matchValues.getAclExpr()) + || LegacyServicePrincipalMatcher.matchesAuthenticatedClient( + serverObjs == null ? null : serverObjs.getCnxn(), matchValues.getId(), matchValues.getAclExpr()); } @Override @@ -171,12 +175,14 @@ private ClientUriDomainMappingHelper getUriDomainMappingHelper(ZooKeeperServer z helper.setDomainAuthUpdater((cnxn, ignoredMap) -> { try { ClientIdentity identity = X509AuthenticationUtil.getClientId(cnxn, trustManager); + cnxn.setX509ClientIdentity(identity); assignAuthInfo(cnxn, identity.getId(), helper.getDomains(identity.getCertificateType(), identity.getId())); } catch (UnsupportedOperationException unsupportedEx) { LOG.info("Cannot update AuthInfo for session 0x{} since the operation is not supported.", Long.toHexString(cnxn.getSessionId())); } catch (KeeperException.AuthFailedException authEx) { + cnxn.setX509ClientIdentity(null); LOG.error("Failed to authenticate session 0x{} for AuthInfo update. Revoking all of its ZNodeGroupAcl AuthInfo.", Long.toHexString(cnxn.getSessionId()), authEx); try { 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 2460dc78d9e..0ff1370bd7c 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 @@ -26,8 +26,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.apache.zookeeper.KeeperException; import org.apache.zookeeper.WatchedEvent; import org.apache.zookeeper.ZooDefs; @@ -35,6 +33,7 @@ import org.apache.zookeeper.server.ServerCnxn; import org.apache.zookeeper.server.ServerCnxnFactory; import org.apache.zookeeper.server.ZooKeeperServer; +import org.apache.zookeeper.server.auth.LegacyServicePrincipalMatcher; import org.apache.zookeeper.server.auth.X509AuthenticationConfig; import org.apache.zookeeper.server.auth.X509AuthenticationUtil.CertificateType; import org.slf4j.Logger; @@ -78,10 +77,6 @@ public class ZkClientUriDomainMappingHelper implements ClientUriDomainMappingHel private static final Logger LOG = LoggerFactory.getLogger(ZkClientUriDomainMappingHelper.class); - // Accept the legacy truncated name, a closed principal, or a complete service-principal URN. - private static final Pattern LEGACY_SERVICE_PRINCIPAL_PATTERN = - Pattern.compile("^(?:urn:li:)?servicePrincipal\\(([^();/]+)(?:\\)|;[^()/]*\\))?$"); - private final ZooKeeperServer zks; private final String rootPath; @@ -229,8 +224,7 @@ public Set getDomains(CertificateType certificateType, String clientUri) if (certificateType == CertificateType.SPIFFE_V1_WL) { Set domains = new HashSet<>(); for (Map.Entry> entry : map.entrySet()) { - Matcher matcher = LEGACY_SERVICE_PRINCIPAL_PATTERN.matcher(entry.getKey()); - if (matcher.matches() && clientUri.equals(matcher.group(1))) { + if (LegacyServicePrincipalMatcher.matches(certificateType, clientUri, entry.getKey())) { domains.addAll(entry.getValue()); } } diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java new file mode 100644 index 00000000000..53d13310921 --- /dev/null +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java @@ -0,0 +1,284 @@ +/* + * 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.server.auth; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.zookeeper.KeeperException; +import org.apache.zookeeper.ZKTestCase; +import org.apache.zookeeper.ZooDefs; +import org.apache.zookeeper.common.SpiffeAuthTestUtil; +import org.apache.zookeeper.data.ACL; +import org.apache.zookeeper.data.Id; +import org.apache.zookeeper.server.MockServerCnxn; +import org.apache.zookeeper.server.PrepRequestProcessor; +import org.apache.zookeeper.server.ZooKeeperServer; +import org.apache.zookeeper.server.auth.znode.groupacl.X509ZNodeGroupAclProvider; +import org.apache.zookeeper.test.X509AuthTest.TestTrustManager; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +public class X509DirectAclTest extends ZKTestCase { + private static final String PROVIDER_PROPERTY = ProviderRegistry.AUTHPROVIDER_PROPERTY_PREFIX + "x509"; + private static final String SUPERUSER_PROPERTY = "zookeeper.X509AuthenticationProvider.superUser"; + private static final String[] PROPERTIES = { + PROVIDER_PROPERTY, + SUPERUSER_PROPERTY, + X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, + X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, + X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, + X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, + X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX + }; + + private final Map originalProperties = new HashMap<>(); + private ZooKeeperServer server; + + @BeforeClass + public static void registerBouncyCastle() { + SpiffeAuthTestUtil.registerBouncyCastle(); + } + + @Before + public void setUp() { + for (String property : PROPERTIES) { + originalProperties.put(property, System.getProperty(property)); + System.clearProperty(property); + } + X509AuthenticationConfig.reset(); + selectProvider(X509AuthenticationProvider.class); + server = new ZooKeeperServer(); + } + + @After + public void tearDown() { + for (String property : PROPERTIES) { + String value = originalProperties.get(property); + if (value == null) { + System.clearProperty(property); + } else { + System.setProperty(property, value); + } + } + ProviderRegistry.reset(); + X509AuthenticationConfig.reset(); + } + + @Test + public void testSpiffeWorkloadMatchesLegacyDirectAclsWithoutChangingAuthInfo() throws Exception { + MockServerCnxn cnxn = authenticate("spiffe://example.org/v1/wl/kafka"); + assertEquals(Collections.singletonList(new Id("x509", "kafka")), cnxn.getAuthInfo()); + for (String id : Arrays.asList( + "kafka", "servicePrincipal(kafka", "servicePrincipal(kafka)", + "urn:li:servicePrincipal(kafka;region1;instance1)")) { + server.checkACL(cnxn, acl(id, ZooDefs.Perms.READ), ZooDefs.Perms.READ, + cnxn.getAuthInfo(), "/protected", null); + } + assertEquals(Collections.singletonList(new ACL(ZooDefs.Perms.ALL, new Id("x509", "kafka"))), + PrepRequestProcessor.fixupACL("/created", cnxn.getAuthInfo(), ZooDefs.Ids.CREATOR_ALL_ACL)); + } + + @Test + public void testAclPermissionAndExactApplicationNameAreStillRequired() throws Exception { + MockServerCnxn cnxn = authenticate("spiffe://example.org/v1/wl/kafka"); + assertDenied(cnxn, "servicePrincipal(kafka", ZooDefs.Perms.READ, ZooDefs.Perms.WRITE); + for (String id : Arrays.asList( + "servicePrincipal(other", "servicePrincipal(kafka-extra", "servicePrincipal(Kafka", + "userPrincipal(kafka", "groupPrincipal(kafka", "servicePrincipalMetadata(kafka)", + "servicePrincipal(kafka)extra", "nested/servicePrincipal(kafka")) { + assertDenied(cnxn, id, ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + } + + @Test + public void testOtherSpiffeTypesDoNotGainLegacyServiceAccess() throws Exception { + for (String path : Arrays.asList( + "/v2/kafka", "/v1/application/example-mp/kafka", "/v2/application/example-mp/kafka", + "/v2/group/kafka", "/v1/user/kafka", "/v2/user/kafka", + "/v2/%75ser/kafka", "/v1/wl/kafka/extra")) { + MockServerCnxn cnxn = authenticate("spiffe://example.org" + path); + assertDenied(cnxn, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + } + + @Test + public void testConfiguredSanWithSameBareNameDoesNotGainSpiffeCompatibility() throws Exception { + configureSan("^urn:example:(.*)$"); + MockServerCnxn cnxn = authenticate("urn:example:kafka"); + assertEquals(Collections.singletonList(new Id("x509", "kafka")), cnxn.getAuthInfo()); + server.checkACL(cnxn, acl("kafka", ZooDefs.Perms.READ), ZooDefs.Perms.READ, + cnxn.getAuthInfo(), "/protected", null); + assertDenied(cnxn, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + + @Test + public void testSubjectDnAndLegacySanExactAclsRemainValid() throws Exception { + MockServerCnxn subject = authenticate("urn:example:kafka"); + server.checkACL(subject, acl("CN=test-client", ZooDefs.Perms.READ), ZooDefs.Perms.READ, + subject.getAuthInfo(), "/protected", null); + assertDenied(subject, "servicePrincipal(CN=test-client", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + + configureSan("^urn:li:(servicePrincipal\\([^;]+)"); + MockServerCnxn legacy = authenticate("urn:li:servicePrincipal(kafka;region1;instance1)"); + assertEquals(Collections.singletonList(new Id("x509", "servicePrincipal(kafka")), legacy.getAuthInfo()); + server.checkACL(legacy, acl("servicePrincipal(kafka", ZooDefs.Perms.READ), ZooDefs.Perms.READ, + legacy.getAuthInfo(), "/protected", null); + server.checkACL(legacy, acl("kafka", ZooDefs.Perms.READ), ZooDefs.Perms.READ, + legacy.getAuthInfo(), "/protected", null); + assertDenied(legacy, "servicePrincipal(other", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + assertDenied(legacy, "kafka", ZooDefs.Perms.READ, ZooDefs.Perms.WRITE); + } + + @Test + public void testReverseCompatibilityRequiresLegacyServiceIdentity() throws Exception { + configureSan("^urn:li:([^;]+)"); + for (String kind : Arrays.asList("userPrincipal", "groupPrincipal", "servicePrincipalMetadata")) { + MockServerCnxn cnxn = authenticate("urn:li:" + kind + "(kafka;region1;instance1)"); + assertDenied(cnxn, "kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + MockServerCnxn spiffe = authenticate("spiffe://example.org/v2/servicePrincipal(kafka"); + assertDenied(spiffe, "kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + + @Test + public void testGroupProviderCannotTreatMappedDomainAsCertificateIdentity() throws Exception { + selectProvider(X509ZNodeGroupAclProvider.class); + assertFalse(ProviderRegistry.getServerProvider("x509").matches( + null, new ServerAuthenticationProvider.MatchValues( + "/protected", "kafka", "servicePrincipal(kafka", ZooDefs.Perms.READ, null))); + MockServerCnxn cnxn = authenticate("spiffe://example.org/v1/wl/kafka"); + server.checkACL(cnxn, acl("servicePrincipal(kafka", ZooDefs.Perms.READ), ZooDefs.Perms.READ, + cnxn.getAuthInfo(), "/protected", null); + + cnxn.addAuthInfo(new Id("x509", "other-domain")); + server.checkACL(cnxn, acl("other-domain", ZooDefs.Perms.READ), ZooDefs.Perms.READ, + cnxn.getAuthInfo(), "/protected", null); + assertDenied(cnxn, "servicePrincipal(other-domain", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + + configureSan("^urn:li:(servicePrincipal\\([^;]+)"); + MockServerCnxn legacy = authenticate("urn:li:servicePrincipal(kafka;region1;instance1)"); + legacy.addAuthInfo(new Id("x509", "servicePrincipal(other")); + server.checkACL(legacy, acl("kafka", ZooDefs.Perms.READ), ZooDefs.Perms.READ, + legacy.getAuthInfo(), "/protected", null); + assertDenied(legacy, "other", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + + @Test + public void testUnauthenticatedConnectionCannotEnableCompatibility() throws Exception { + MockServerCnxn missing = new MockServerCnxn(); + missing.addAuthInfo(new Id("x509", "kafka")); + assertDenied(missing, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + + MockServerCnxn unsupported = new MockServerCnxn() { + @Override + public Certificate[] getClientCertificateChain() { + throw new UnsupportedOperationException("No TLS certificate support"); + } + }; + unsupported.addAuthInfo(new Id("x509", "kafka")); + assertDenied(unsupported, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + + MockServerCnxn certificateOnly = new MockServerCnxn(); + certificateOnly.clientChain = new X509Certificate[]{ + SpiffeAuthTestUtil.buildClientCertWithUriSans("spiffe://example.org/v1/wl/kafka") + }; + certificateOnly.addAuthInfo(new Id("x509", "kafka")); + assertDenied(certificateOnly, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + + @Test + public void testFailedAuthenticationClearsCompatibilityIdentity() throws Exception { + MockServerCnxn cnxn = authenticate("spiffe://example.org/v1/wl/kafka"); + X509Certificate differentCert = + SpiffeAuthTestUtil.buildClientCertWithUriSans("spiffe://example.org/v1/wl/other"); + X509AuthenticationProvider provider = new X509AuthenticationProvider( + new TestTrustManager(differentCert), new SpiffeAuthTestUtil.NoopKeyManager()); + + assertEquals(KeeperException.Code.AUTHFAILED, provider.handleAuthentication(cnxn, null)); + assertNull(cnxn.getX509ClientIdentity()); + assertDenied(cnxn, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + + @Test + public void testCompatibilityDoesNotPromoteConfiguredSuperuserAlias() throws Exception { + System.setProperty(SUPERUSER_PROPERTY, "servicePrincipal(kafka"); + MockServerCnxn cnxn = authenticate("spiffe://example.org/v1/wl/kafka"); + assertEquals(Collections.singletonList(new Id("x509", "kafka")), cnxn.getAuthInfo()); + assertDenied(cnxn, "servicePrincipal(other", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + + @Test + public void testLegacyProviderWrapperKeepsExistingStringMatcher() { + ServerAuthenticationProvider provider = ProviderRegistry.getServerProvider("ip"); + assertTrue(provider.matches(null, new ServerAuthenticationProvider.MatchValues( + "/protected", "10.1.2.3", "10.0.0.0/8", ZooDefs.Perms.READ, null))); + assertFalse(provider.matches(null, new ServerAuthenticationProvider.MatchValues( + "/protected", "192.0.2.1", "10.0.0.0/8", ZooDefs.Perms.READ, null))); + } + + private MockServerCnxn authenticate(String... uriSans) throws Exception { + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans(uriSans); + X509AuthenticationProvider provider = new X509AuthenticationProvider( + new SpiffeAuthTestUtil.AcceptAllTrustManager(), new SpiffeAuthTestUtil.NoopKeyManager()); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{cert}; + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + return cnxn; + } + + private void assertDenied(MockServerCnxn cnxn, String id, int allowedPerms, int requestedPerm) { + try { + server.checkACL(cnxn, acl(id, allowedPerms), requestedPerm, cnxn.getAuthInfo(), "/protected", null); + fail("Unexpected access to ACL " + id); + } catch (KeeperException.NoAuthException expected) { + // Expected denial. + } + } + + private static List acl(String id, int perms) { + return Collections.singletonList(new ACL(perms, new Id("x509", id))); + } + + private static void selectProvider(Class providerClass) { + System.setProperty(PROVIDER_PROPERTY, providerClass.getName()); + ProviderRegistry.reset(); + } + + private static void configureSan(String extractRegex) { + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, "6"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, "^urn:"); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_REGEX, extractRegex); + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_EXTRACT_MATCHER_GROUP_INDEX, "1"); + X509AuthenticationConfig.reset(); + } +} From daddaac7f8fd5b11bf02a3c416dd34adcf78fa54 Mon Sep 17 00:00:00 2001 From: Aditi Bansal Date: Tue, 15 Sep 2026 09:43:30 +0530 Subject: [PATCH 12/16] Support complete SPIFFE application identities in legacy matching Try exact and segment-prefix domain mappings before legacy service-principal fallback. Require an explicit application/product/app path with an optional tag before comparing the application segment; retain existing v1/wl support. Reuse the same rule for direct ACLs without changing certificate extraction, stored identities, permissions or authenticated-ID binding. Preserve legacy-to-bare-name compatibility without inferring product or tag context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../auth/LegacyServicePrincipalMatcher.java | 25 ++-- .../ZkClientUriDomainMappingHelper.java | 49 ++++---- .../server/auth/X509DirectAclTest.java | 55 +++++++-- .../ZkClientUriDomainMappingHelperTest.java | 114 +++++++++++++++--- 4 files changed, 188 insertions(+), 55 deletions(-) diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java index f51b4e7fac6..968cc33155b 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java @@ -30,26 +30,37 @@ public final class LegacyServicePrincipalMatcher { private static final Pattern LEGACY_SERVICE_PRINCIPAL_PATTERN = Pattern.compile("^(?:urn:li:)?servicePrincipal\\(([^();/]+)(?:\\)|;[^()/]*\\))?$"); + private static final Pattern SPIFFE_APPLICATION_PATTERN = + Pattern.compile("^application/[^/]+/([^/]+)(?:/[^/]+)?$"); private LegacyServicePrincipalMatcher() { } public static boolean matches(CertificateType certificateType, String clientId, String legacyPrincipal) { - return certificateType == CertificateType.SPIFFE_V1_WL - && clientId != null && clientId.equals(getApplicationName(legacyPrincipal)); + String applicationName = getApplicationName(legacyPrincipal); + if (applicationName == null || clientId == null) { + return false; + } + if (certificateType == CertificateType.SPIFFE_V1_WL) { + return applicationName.equals(clientId); + } + if (certificateType == CertificateType.SPIFFE_V1_WORKLOAD || certificateType == CertificateType.SPIFFE_V2) { + Matcher matcher = SPIFFE_APPLICATION_PATTERN.matcher(clientId); + return matcher.matches() && applicationName.equals(matcher.group(1)); + } + return false; } public static boolean matchesAuthenticatedClient(ServerCnxn cnxn, String authenticatedId, String aclId) { - boolean workloadToLegacy = authenticatedId != null && authenticatedId.equals(getApplicationName(aclId)); - boolean legacyToWorkload = aclId != null && aclId.equals(getApplicationName(authenticatedId)); - if (cnxn == null || (!workloadToLegacy && !legacyToWorkload)) { + if (cnxn == null) { return false; } // Bind the candidate AuthInfo ID to the authenticated certificate identity, not a mapped domain. ClientIdentity identity = cnxn.getX509ClientIdentity(); return identity != null && identity.getId().equals(authenticatedId) - && ((identity.getCertificateType() == CertificateType.SPIFFE_V1_WL && workloadToLegacy) - || (identity.getCertificateType() == CertificateType.LEGACY_SAN && legacyToWorkload)); + && (matches(identity.getCertificateType(), authenticatedId, aclId) + || (identity.getCertificateType() == CertificateType.LEGACY_SAN + && aclId != null && aclId.equals(getApplicationName(authenticatedId)))); } private static String getApplicationName(String legacyPrincipal) { 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 0ff1370bd7c..85f22f99557 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 @@ -205,8 +205,9 @@ public Set getDomains(String clientUri) { } /** - * After exact lookup, only SPIFFE v1/wl identities may match the application name in - * a legacy service-principal znode. Other types retain the existing exact/prefix lookup. + * After exact and segment-prefix lookup miss, SPIFFE v1/wl and v1/v2 application identities + * may match the application name in a legacy service-principal znode. Application paths must + * contain both MP and app segments, with an optional tag; other principal kinds are not aliases. */ @Override public Set getDomains(CertificateType certificateType, String clientUri) { @@ -221,29 +222,33 @@ public Set getDomains(CertificateType certificateType, String clientUri) if (exact != null) { return exact; } - if (certificateType == CertificateType.SPIFFE_V1_WL) { - Set domains = new HashSet<>(); - for (Map.Entry> entry : map.entrySet()) { - if (LegacyServicePrincipalMatcher.matches(certificateType, clientUri, entry.getKey())) { - domains.addAll(entry.getValue()); + Set result = new HashSet<>(); + if (clientUri.indexOf('/') >= 0) { + boolean prefixMatched = false; + String[] segments = clientUri.split("/"); + 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) { + prefixMatched = true; + result.addAll(match); } } - return domains.isEmpty() ? Collections.emptySet() : domains; - } - 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('/'); + if (prefixMatched) { + return result.isEmpty() ? Collections.emptySet() : result; } - prefix.append(segments[n - 1]); - Set match = map.get(prefix.toString()); - if (match != null) { - result.addAll(match); + } + if (certificateType == CertificateType.SPIFFE_V1_WL + || certificateType == CertificateType.SPIFFE_V1_WORKLOAD + || certificateType == CertificateType.SPIFFE_V2) { + for (Map.Entry> entry : map.entrySet()) { + if (LegacyServicePrincipalMatcher.matches(certificateType, clientUri, entry.getKey())) { + result.addAll(entry.getValue()); + } } } return result.isEmpty() ? Collections.emptySet() : result; diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java index 53d13310921..6407540fad7 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java @@ -107,6 +107,38 @@ public void testSpiffeWorkloadMatchesLegacyDirectAclsWithoutChangingAuthInfo() t PrepRequestProcessor.fixupACL("/created", cnxn.getAuthInfo(), ZooDefs.Ids.CREATOR_ALL_ACL)); } + @Test + public void testApplicationIdentitiesMatchLegacyAclsWithoutChangingFullIdentity() throws Exception { + for (Class providerClass : Arrays.asList( + X509AuthenticationProvider.class, X509ZNodeGroupAclProvider.class)) { + selectProvider(providerClass); + for (String path : Arrays.asList( + "/v1/application/example-mp/kafka", "/v1/application/example-mp/kafka/cluster-a", + "/v2/application/example-mp/kafka", "/v2/application/example-mp/kafka/cluster-a")) { + MockServerCnxn cnxn = authenticate("spiffe://example.org" + path); + String clientId = path.substring("/v1/".length()); + assertEquals(Collections.singletonList(new Id("x509", clientId)), cnxn.getAuthInfo()); + for (String id : Arrays.asList( + clientId, "servicePrincipal(kafka", "servicePrincipal(kafka)", + "urn:li:servicePrincipal(kafka;region1;instance1)")) { + server.checkACL(cnxn, acl(id, ZooDefs.Perms.READ), ZooDefs.Perms.READ, + cnxn.getAuthInfo(), "/protected", null); + } + assertEquals(Collections.singletonList(new ACL(ZooDefs.Perms.ALL, new Id("x509", clientId))), + PrepRequestProcessor.fixupACL("/created", cnxn.getAuthInfo(), ZooDefs.Ids.CREATOR_ALL_ACL)); + assertDenied(cnxn, "servicePrincipal(kafka", ZooDefs.Perms.READ, ZooDefs.Perms.WRITE); + for (String id : Arrays.asList( + "kafka", "servicePrincipal(example-mp", "servicePrincipal(cluster-a", + "servicePrincipal(kafka-extra", "servicePrincipal(Kafka", + "userPrincipal(kafka", "groupPrincipal(kafka", "servicePrincipalMetadata(kafka)")) { + assertDenied(cnxn, id, ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + cnxn.addAuthInfo(new Id("x509", "application/other-mp/other-app")); + assertDenied(cnxn, "servicePrincipal(other-app", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + } + } + @Test public void testAclPermissionAndExactApplicationNameAreStillRequired() throws Exception { MockServerCnxn cnxn = authenticate("spiffe://example.org/v1/wl/kafka"); @@ -122,7 +154,12 @@ public void testAclPermissionAndExactApplicationNameAreStillRequired() throws Ex @Test public void testOtherSpiffeTypesDoNotGainLegacyServiceAccess() throws Exception { for (String path : Arrays.asList( - "/v2/kafka", "/v1/application/example-mp/kafka", "/v2/application/example-mp/kafka", + "/v2/kafka", "/v1/application/kafka", "/v2/application/kafka", + "/v1/airflow/kafka", "/v2/airflow/kafka", "/v2/workload/example-mp/kafka", + "/v2/application//kafka", "/v2/application/example-mp/kafka/", + "/v2/application/example-mp/kafka//cluster-a", "/v2/application/example-mp/kafka/tag/extra", + "/v2/application/kafka/other", "/v2/application/example-mp/other/kafka", + "/v2/group/application/example-mp/kafka", "/v2/group/kafka", "/v1/user/kafka", "/v2/user/kafka", "/v2/%75ser/kafka", "/v1/wl/kafka/extra")) { MockServerCnxn cnxn = authenticate("spiffe://example.org" + path); @@ -131,13 +168,15 @@ public void testOtherSpiffeTypesDoNotGainLegacyServiceAccess() throws Exception } @Test - public void testConfiguredSanWithSameBareNameDoesNotGainSpiffeCompatibility() throws Exception { + public void testConfiguredSanWithSameNameDoesNotGainSpiffeCompatibility() throws Exception { configureSan("^urn:example:(.*)$"); - MockServerCnxn cnxn = authenticate("urn:example:kafka"); - assertEquals(Collections.singletonList(new Id("x509", "kafka")), cnxn.getAuthInfo()); - server.checkACL(cnxn, acl("kafka", ZooDefs.Perms.READ), ZooDefs.Perms.READ, - cnxn.getAuthInfo(), "/protected", null); - assertDenied(cnxn, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + for (String id : Arrays.asList("kafka", "application/example-mp/kafka")) { + MockServerCnxn cnxn = authenticate("urn:example:" + id); + assertEquals(Collections.singletonList(new Id("x509", id)), cnxn.getAuthInfo()); + server.checkACL(cnxn, acl(id, ZooDefs.Perms.READ), ZooDefs.Perms.READ, + cnxn.getAuthInfo(), "/protected", null); + assertDenied(cnxn, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } } @Test @@ -156,6 +195,8 @@ public void testSubjectDnAndLegacySanExactAclsRemainValid() throws Exception { legacy.getAuthInfo(), "/protected", null); assertDenied(legacy, "servicePrincipal(other", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); assertDenied(legacy, "kafka", ZooDefs.Perms.READ, ZooDefs.Perms.WRITE); + assertDenied(legacy, "application/example-mp/kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + assertDenied(legacy, "application/example-mp/kafka/cluster-a", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); } @Test 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 102edb98724..7191f8e086e 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 @@ -341,7 +341,7 @@ public void testA4_SpiffeCertResolvesViaPrefixWalkUpToDomainAuthInfo() throws Ex } @Test - public void testA5_SpiffeWorkloadUsesLegacyMappingAndObservesUpdates() throws Exception { + public void testA5_SpiffeApplicationsUseLegacyMappingAndObserveUpdates() throws Exception { for (String path : new String[] { CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH, CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access", @@ -349,27 +349,32 @@ public void testA5_SpiffeWorkloadUsesLegacyMappingAndObservesUpdates() throws Ex }) { zookeeperClientConnection.create(path, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); } - X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans( - "spiffe://example.org/v1/wl/kafka"); X509ZNodeGroupAclProvider provider = new X509ZNodeGroupAclProvider( new SpiffeAuthTestUtil.AcceptAllTrustManager(), new SpiffeAuthTestUtil.NoopKeyManager()); - MockServerCnxn cnxn = new MockServerCnxn(); - cnxn.clientChain = new X509Certificate[]{cert}; - ServerAuthenticationProvider.ServerObjs serverObjs = - new ServerAuthenticationProvider.ServerObjs(zookeeperServer, cnxn); + String mappingPath = CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/servicePrincipal(kafka"; + for (String path : Arrays.asList( + "/v1/wl/kafka", "/v1/application/example-mp/kafka", + "/v2/application/example-mp/kafka", "/v2/application/example-mp/kafka/cluster-a")) { + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans("spiffe://example.org" + path); + String clientId = path.equals("/v1/wl/kafka") ? "kafka" : path.substring("/v1/".length()); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{cert}; + ServerAuthenticationProvider.ServerObjs serverObjs = + new ServerAuthenticationProvider.ServerObjs(zookeeperServer, cnxn); - Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); - Assert.assertTrue(cnxn.getAuthInfo().contains(new Id("x509", "broker-access"))); + Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); + Assert.assertTrue(cnxn.getAuthInfo().contains(new Id("x509", "broker-access"))); + Assert.assertEquals(clientId, cnxn.getX509ClientIdentity().getId()); - String mappingPath = CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/servicePrincipal(kafka"; - zookeeperClientConnection.delete(mappingPath, -1); - Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); - Assert.assertFalse(cnxn.getAuthInfo().contains(new Id("x509", "broker-access"))); - Assert.assertTrue(cnxn.getAuthInfo().contains(new Id("x509", "kafka"))); - - zookeeperClientConnection.create(mappingPath, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); - Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); - Assert.assertTrue(cnxn.getAuthInfo().contains(new Id("x509", "broker-access"))); + zookeeperClientConnection.delete(mappingPath, -1); + Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); + Assert.assertFalse(cnxn.getAuthInfo().contains(new Id("x509", "broker-access"))); + Assert.assertTrue(cnxn.getAuthInfo().contains(new Id("x509", clientId))); + + zookeeperClientConnection.create(mappingPath, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); + Assert.assertTrue(cnxn.getAuthInfo().contains(new Id("x509", "broker-access"))); + } } @Test @@ -499,6 +504,13 @@ public void testD_SpiffeWorkloadMatchesLegacyServicePrincipalNames() { Assert.assertEquals(new HashSet<>(Arrays.asList("truncated-domain", "closed-domain", "urn-domain")), helper.getDomains(CertificateType.SPIFFE_V1_WL, "kafka")); + for (CertificateType type : Arrays.asList( + CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { + for (String clientId : Arrays.asList("application/example-mp/kafka", "application/example-mp/kafka/cluster-a")) { + Assert.assertEquals(new HashSet<>(Arrays.asList("truncated-domain", "closed-domain", "urn-domain")), + helper.getDomains(type, clientId)); + } + } Assert.assertEquals(Collections.singleton("truncated-domain"), helper.getDomains(CertificateType.LEGACY_SAN, "servicePrincipal(kafka")); Assert.assertEquals(Collections.singleton("urn-domain"), @@ -525,10 +537,17 @@ public void testD_ExactIdentityMappingOverridesLegacyAlias() { Map> mapping = new HashMap<>(); mapping.put("kafka", Collections.singleton("explicit-domain")); mapping.put("servicePrincipal(kafka", Collections.singleton("legacy-domain")); + mapping.put("application/example-mp", Collections.singleton("prefix-domain")); + mapping.put("application/example-mp/kafka", Collections.singleton("exact-domain")); setMapping(helper, mapping); Assert.assertEquals(Collections.singleton("explicit-domain"), helper.getDomains(CertificateType.SPIFFE_V1_WL, "kafka")); + for (CertificateType type : Arrays.asList( + CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { + Assert.assertEquals(Collections.singleton("exact-domain"), + helper.getDomains(type, "application/example-mp/kafka")); + } } @Test @@ -543,10 +562,63 @@ public void testD_TypedMultiSegmentLookupRetainsPrefixMatching() { CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { Assert.assertEquals(Collections.singleton("path-domain"), helper.getDomains(type, "application/example-mp/kafka")); - Assert.assertEquals(Collections.emptySet(), + Assert.assertEquals(Collections.singleton("legacy-domain"), helper.getDomains(type, "application/unrelated-mp/kafka")); Assert.assertEquals(Collections.emptySet(), helper.getDomains(type, "group/kafka")); } + mapping.put("application/example-mp/kafka", Collections.singleton("app-domain")); + for (CertificateType type : Arrays.asList( + CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { + Assert.assertEquals(new HashSet<>(Arrays.asList("path-domain", "app-domain")), + helper.getDomains(type, "application/example-mp/kafka/cluster-a")); + } + } + + @Test + public void testD_EmptyPrefixMappingDoesNotFallBackToLegacy() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + Map> mapping = new HashMap<>(); + mapping.put("servicePrincipal(kafka", Collections.singleton("legacy-domain")); + mapping.put("application/example-mp", Collections.emptySet()); + setMapping(helper, mapping); + + for (CertificateType type : Arrays.asList( + CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { + Assert.assertEquals(Collections.emptySet(), helper.getDomains(type, "application/example-mp/kafka")); + } + } + + @Test + public void testD_ApplicationAliasesRequireCompleteApplicationPath() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + setMapping(helper, Collections.singletonMap("servicePrincipal(kafka", Collections.singleton("broker-access"))); + + for (CertificateType type : Arrays.asList( + CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { + for (String clientId : Arrays.asList( + "application", "application/kafka", "application/kafka/", "application//kafka", + "application/example-mp/kafka/", "application/example-mp/kafka//cluster-a", + "application/example-mp/kafka/tag/extra", "application/kafka/other", + "application/example-mp/other/kafka", "application/example-mp/kafka-extra", + "application/example-mp/Kafka", "application/example-mp/%6bafka", + "Application/example-mp/kafka", "workload/example-mp/kafka", + "airflow/kafka", "group/kafka", "user/kafka", "group/application/example-mp/kafka")) { + Assert.assertEquals(type + ": " + clientId, Collections.emptySet(), helper.getDomains(type, clientId)); + } + } + } + + @Test + public void testD_ApplicationAliasesRequireSpiffeCertificateType() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + setMapping(helper, Collections.singletonMap("servicePrincipal(kafka", Collections.singleton("broker-access"))); + + for (CertificateType type : Arrays.asList( + CertificateType.SPIFFE_V1_WL, CertificateType.LEGACY_SAN, CertificateType.SUBJECT_DN)) { + Assert.assertEquals(type.name(), Collections.emptySet(), + helper.getDomains(type, "application/example-mp/kafka")); + } + Assert.assertEquals(Collections.emptySet(), helper.getDomains("application/example-mp/kafka")); } @Test @@ -571,6 +643,10 @@ public void testD_OtherPrincipalKindsAndMalformedNamesAreNotAliases() { setMapping(helper, mapping); Assert.assertEquals(Collections.emptySet(), helper.getDomains(CertificateType.SPIFFE_V1_WL, "kafka")); + for (CertificateType type : Arrays.asList( + CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { + Assert.assertEquals(Collections.emptySet(), helper.getDomains(type, "application/example-mp/kafka")); + } Assert.assertEquals(Collections.singleton("unrelated-domain"), helper.getDomains(CertificateType.LEGACY_SAN, "userPrincipal(kafka")); Assert.assertEquals(Collections.singleton("unrelated-domain"), From 3674d23f7df1e104a86e2774a5a8a17457984ce5 Mon Sep 17 00:00:00 2001 From: Aditi Bansal Date: Tue, 22 Sep 2026 08:37:06 +0530 Subject: [PATCH 13/16] Add opt-in legacy superuser compatibility for SPIFFE identities Keep exact superuser matches first and share typed SPIFFE-to-legacy service-principal selection between both X509 providers. The new zookeeper.ssl.x509.legacySuperUserCompatibilityEnabled setting defaults to false. Preserve the original certificate identity and use the matched configured ID as the super marker, retaining explicit-superuser ACL handling without changing cross-domain policy. Document activation and privilege scope and add focused regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/resources/markdown/zookeeperAdmin.md | 23 ++++ .../auth/LegacyServicePrincipalMatcher.java | 21 +++- .../server/auth/X509AuthenticationConfig.java | 12 +- .../auth/X509AuthenticationProvider.java | 11 +- .../groupacl/X509ZNodeGroupAclProvider.java | 18 ++- .../server/auth/X509DirectAclTest.java | 94 ++++++++++++++ .../X509ZNodeGroupAclProviderTest.java | 117 +++++++++++++++++- 7 files changed, 280 insertions(+), 16 deletions(-) diff --git a/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md b/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md index d015079d7dc..a76cc726de9 100644 --- a/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md +++ b/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md @@ -1368,6 +1368,29 @@ and [SASL authentication for ZooKeeper](https://cwiki.apache.org/confluence/disp authenticated client with that principal will be able to bypass ACL checking and have full privileges to all znodes. +* *ssl.x509.legacySuperUserCompatibilityEnabled* : + (Java system property: **zookeeper.ssl.x509.legacySuperUserCompatibilityEnabled**) + **Default: false.** When enabled, both **X509AuthenticationProvider** and + **X509ZNodeGroupAclProvider** can match an authenticated SPIFFE v1/wl identity, + or a v1/v2 **application/\/\[/\]** identity, against an + existing legacy service-principal superuser ID by application name. Exact + configured client-ID matches take precedence and do not require this option. + Supported legacy forms include **servicePrincipal(kafka**, + **servicePrincipal(kafka)**, and **urn:li:servicePrincipal(kafka;region1;instance1)**. + The existing superuser settings remain **zookeeper.X509AuthenticationProvider.superUser** + and **zookeeper.X509ZNodeGroupAclProvider.superUserId**, respectively. + The original certificate-derived identity is preserved; the **super** AuthInfo + marker uses the matched configured ID so explicit superusers remain distinct + from cross-domain components during ACL preparation. + This option does not enable reverse legacy-to-SPIFFE superuser aliases or + compatibility for user, group, airflow, arbitrary v2, or Subject DN identities. + **Warning:** legacy application names do not distinguish products or tags; + enable this option only when all eligible trusted identities with that app + name should have full superuser privileges. It does not change certificate + trust validation or existing cross-domain grants. Treat it as a startup + setting and restart servers or reconnect clients when changing it; it is not + an immediate revocation mechanism for already authenticated connections. + * *zookeeper.superUser* : (Java system property: **zookeeper.superUser**) Similar to **zookeeper.X509AuthenticationProvider.superUser** diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java index 968cc33155b..11faf5c071d 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java @@ -18,6 +18,8 @@ package org.apache.zookeeper.server.auth; +import java.util.Optional; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.zookeeper.server.ServerCnxn; @@ -25,7 +27,7 @@ import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; /** - * Compatibility matching shared by direct X509 ACLs and URI-domain mappings. + * Compatibility matching shared by X509 ACLs, URI-domain mappings and opt-in superuser selection. */ public final class LegacyServicePrincipalMatcher { private static final Pattern LEGACY_SERVICE_PRINCIPAL_PATTERN = @@ -36,6 +38,23 @@ public final class LegacyServicePrincipalMatcher { private LegacyServicePrincipalMatcher() { } + /** + * Return the configured ID so ACL preparation continues to recognize an explicit superuser. + * Exact matches take precedence; otherwise choose a stable marker among compatible IDs. + */ + public static Optional findMatchingSuperUserId(ClientIdentity identity, Set configuredIds) { + if (configuredIds.contains(identity.getId())) { + return Optional.of(identity.getId()); + } + if (!X509AuthenticationConfig.getInstance().isLegacySuperUserCompatibilityEnabled()) { + return Optional.empty(); + } + return configuredIds.stream() + .filter(id -> matches(identity.getCertificateType(), identity.getId(), id)) + .sorted() + .findFirst(); + } + public static boolean matches(CertificateType certificateType, String clientId, String legacyPrincipal) { String applicationName = getApplicationName(legacyPrincipal); if (applicationName == null || clientId == null) { 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 e5a95d9ea6a..6a0a019c934 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 @@ -56,7 +56,7 @@ public static X509AuthenticationConfig getInstance() { return instance; } - // The following System Property keys are used to extract clientId from the client cert. + // Shared X509 authentication settings. /** * Config prefix for x509-related config properties. @@ -64,6 +64,12 @@ public static X509AuthenticationConfig getInstance() { * and {@link org.apache.zookeeper.server.auth.znode.groupacl.X509ZNodeGroupAclProvider} */ public static final String SSL_X509_CONFIG_PREFIX = "zookeeper.ssl.x509."; + /** + * Opt-in matching of SPIFFE application names against legacy service-principal superuser IDs. + * Disabled by default; applies to both providers and does not distinguish products or tags. + */ + public static final String SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED = + SSL_X509_CONFIG_PREFIX + "legacySuperUserCompatibilityEnabled"; /** * Determines which field in the x509 certificate to be used for client Id: * SAN (subject alternative name) or SDN (subject domain name) (default) @@ -272,6 +278,10 @@ public void setStoreAuthedClientIdEnabled(String enabled) { // Getters for X509 properties + public boolean isLegacySuperUserCompatibilityEnabled() { + return Boolean.parseBoolean(System.getProperty(SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "false")); + } + public String getClientCertIdType() { if (clientCertIdType == null) { setClientCertIdType(System.getProperty(SSL_X509_CLIENT_CERT_ID_TYPE)); diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java index 6b90e977d23..64d8710a92e 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java @@ -19,6 +19,8 @@ package org.apache.zookeeper.server.auth; import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Optional; import javax.net.ssl.X509KeyManager; import javax.net.ssl.X509TrustManager; import javax.security.auth.x500.X500Principal; @@ -100,9 +102,12 @@ public KeeperException.Code handleAuthentication(ServerCnxn cnxn, byte[] authDat cnxn.setX509ClientIdentity(identity); String clientId = identity.getId(); - if (clientId.equals(System.getProperty(ZOOKEEPER_X509AUTHENTICATIONPROVIDER_SUPERUSER))) { - cnxn.addAuthInfo(new Id(X509AuthenticationUtil.SUPERUSER_AUTH_SCHEME, clientId)); - LOG.info("Authenticated Id '{}' as super user", clientId); + String configuredSuperUser = System.getProperty(ZOOKEEPER_X509AUTHENTICATIONPROVIDER_SUPERUSER); + Optional superUserId = LegacyServicePrincipalMatcher.findMatchingSuperUserId(identity, + configuredSuperUser == null ? Collections.emptySet() : Collections.singleton(configuredSuperUser)); + if (superUserId.isPresent()) { + cnxn.addAuthInfo(new Id(X509AuthenticationUtil.SUPERUSER_AUTH_SCHEME, superUserId.get())); + LOG.info("Authenticated Id '{}' as configured super user '{}'", clientId, superUserId.get()); } Id authInfo = new Id(getScheme(), clientId); 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 bbd5489a83d..2176634a033 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 @@ -21,6 +21,7 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import javax.net.ssl.X509KeyManager; @@ -176,7 +177,7 @@ private ClientUriDomainMappingHelper getUriDomainMappingHelper(ZooKeeperServer z try { ClientIdentity identity = X509AuthenticationUtil.getClientId(cnxn, trustManager); cnxn.setX509ClientIdentity(identity); - assignAuthInfo(cnxn, identity.getId(), + assignAuthInfo(cnxn, identity, helper.getDomains(identity.getCertificateType(), identity.getId())); } catch (UnsupportedOperationException unsupportedEx) { LOG.info("Cannot update AuthInfo for session 0x{} since the operation is not supported.", @@ -214,17 +215,14 @@ private ClientUriDomainMappingHelper getUriDomainMappingHelper(ZooKeeperServer z * concurrency control is required to prevent inconsistent update. * * @param cnxn Client connection to be updated - * @param clientId ClientId to be potentially used as the AuthInfo Id if the client is super user. - * The clientId can be any string matched and extracted using regex from Subject Distinguished - * Name or Subject Alternative Name from x509 certificate. - * The clientId string is intended to be an URI for client and map the client to certain domain. - * The user can use the properties defined in X509AuthenticationUtil to extract a desired string as - * clientId. + * @param identity Authenticated certificate type and original client ID. * @param domains Domains to be used as the AuthInfo Id. */ - private void assignAuthInfo(ServerCnxn cnxn, String clientId, Set domains) { + private void assignAuthInfo(ServerCnxn cnxn, ClientIdentity identity, Set domains) { + String clientId = identity.getId(); Set superUserDomainNames = X509AuthenticationConfig.getInstance().getZnodeGroupAclCrossDomainAccessDomains(); Set superUsers = X509AuthenticationConfig.getInstance().getZnodeGroupAclSuperUserIds(); + Optional superUserId = LegacyServicePrincipalMatcher.findMatchingSuperUserId(identity, superUsers); Set newAuthIds = new HashSet<>(); @@ -233,8 +231,8 @@ private void assignAuthInfo(ServerCnxn cnxn, String clientId, Set domain superUserDomainNames.stream().filter(domains::contains).collect(Collectors.toList()); // Check if user belongs to super user id group - if (superUsers.contains(clientId)) { - newAuthIds.add(new Id(X509AuthenticationUtil.SUPERUSER_AUTH_SCHEME, clientId)); + if (superUserId.isPresent()) { + newAuthIds.add(new Id(X509AuthenticationUtil.SUPERUSER_AUTH_SCHEME, superUserId.get())); } else if (!commonSuperUserDomains.isEmpty()) { // For cross domain components, add (super:domainName) in authInfo // "super" scheme gives access to all znodes without checking znode ACL vs authorized domain name diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java index 6407540fad7..07778ca37b9 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java @@ -53,6 +53,7 @@ public class X509DirectAclTest extends ZKTestCase { private static final String[] PROPERTIES = { PROVIDER_PROPERTY, SUPERUSER_PROPERTY, + X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_TYPE, X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_SAN_MATCH_REGEX, @@ -277,6 +278,99 @@ public void testCompatibilityDoesNotPromoteConfiguredSuperuserAlias() throws Exc assertDenied(cnxn, "servicePrincipal(other", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); } + @Test + public void testOptInSuperUserCompatibilityPreservesOriginalIdentity() throws Exception { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + for (String configuredId : Arrays.asList( + "servicePrincipal(kafka", "servicePrincipal(kafka)", + "urn:li:servicePrincipal(kafka;region1;instance1)")) { + System.setProperty(SUPERUSER_PROPERTY, configuredId); + for (String path : Arrays.asList( + "/v1/wl/kafka", "/v1/application/example-mp/kafka", + "/v2/application/example-mp/kafka", "/v2/application/example-mp/kafka/blue", + "/v2/application/other-mp/kafka")) { + String clientId = path.equals("/v1/wl/kafka") ? "kafka" : path.substring("/v1/".length()); + MockServerCnxn cnxn = authenticate("spiffe://example.org" + path); + assertEquals(clientId, cnxn.getX509ClientIdentity().getId()); + assertEquals(2, cnxn.getAuthInfo().size()); + assertTrue(cnxn.getAuthInfo().contains(new Id("super", configuredId))); + assertTrue(cnxn.getAuthInfo().contains(new Id("x509", clientId))); + server.checkACL(cnxn, acl("unrelated", ZooDefs.Perms.READ), ZooDefs.Perms.ADMIN, + cnxn.getAuthInfo(), "/protected", null); + assertEquals(Collections.singletonList(new ACL(ZooDefs.Perms.ALL, new Id("x509", clientId))), + PrepRequestProcessor.fixupACL("/created", cnxn.getAuthInfo(), ZooDefs.Ids.CREATOR_ALL_ACL)); + } + } + } + + @Test + public void testExactSuperUserDoesNotRequireCompatibility() throws Exception { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "false"); + System.setProperty(SUPERUSER_PROPERTY, "servicePrincipal(kafka"); + MockServerCnxn normal = authenticate("spiffe://example.org/v2/application/example-mp/kafka"); + assertDenied(normal, "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + + System.setProperty(SUPERUSER_PROPERTY, "application/example-mp/kafka"); + MockServerCnxn exact = authenticate("spiffe://example.org/v2/application/example-mp/kafka"); + assertTrue(exact.getAuthInfo().contains(new Id("super", "application/example-mp/kafka"))); + } + + @Test + public void testSuperUserCompatibilityRejectsOtherIdentityTypes() throws Exception { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + System.setProperty(SUPERUSER_PROPERTY, "servicePrincipal(kafka"); + for (String path : Arrays.asList( + "/v2/kafka", "/v2/user/kafka", "/v2/group/kafka", "/v1/airflow/kafka", + "/v2/workload/example-mp/kafka", "/v2/application/kafka", + "/v2/application//kafka", "/v2/application/example-mp/other/kafka")) { + MockServerCnxn cnxn = authenticate("spiffe://example.org" + path); + assertDenied(cnxn, "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + MockServerCnxn subject = authenticate("urn:example:kafka"); + assertDenied(subject, "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + + configureSan("^urn:example:(.*)$"); + for (String id : Arrays.asList("kafka", "application/example-mp/kafka")) { + assertDenied(authenticate("urn:example:" + id), "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + configureSan("^urn:li:(servicePrincipal\\([^;]+)"); + System.setProperty(SUPERUSER_PROPERTY, "kafka"); + assertDenied(authenticate("urn:li:servicePrincipal(kafka;region1;instance1)"), + "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + + @Test + public void testSuperUserCompatibilityRequiresMatchingServicePrincipalConfig() throws Exception { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + for (String configuredId : Arrays.asList( + "servicePrincipal(other", "servicePrincipal(kafka-extra", "servicePrincipal(Kafka", + "userPrincipal(kafka", "groupPrincipal(kafka", "servicePrincipalMetadata(kafka)", + "servicePrincipal(kafka)extra")) { + System.setProperty(SUPERUSER_PROPERTY, configuredId); + assertDenied(authenticate("spiffe://example.org/v2/application/example-mp/kafka"), + "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + System.clearProperty(SUPERUSER_PROPERTY); + assertDenied(authenticate("spiffe://example.org/v1/wl/kafka"), + "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + + @Test + public void testUntrustedCertificateCannotGainCompatibleSuperIdentity() throws Exception { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + System.setProperty(SUPERUSER_PROPERTY, "servicePrincipal(kafka"); + X509Certificate trusted = SpiffeAuthTestUtil.buildClientCertWithUriSans("spiffe://example.org/v1/wl/other"); + X509AuthenticationProvider provider = new X509AuthenticationProvider( + new TestTrustManager(trusted), new SpiffeAuthTestUtil.NoopKeyManager()); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{ + SpiffeAuthTestUtil.buildClientCertWithUriSans("spiffe://example.org/v2/application/example-mp/kafka") + }; + assertEquals(KeeperException.Code.AUTHFAILED, provider.handleAuthentication(cnxn, null)); + assertNull(cnxn.getX509ClientIdentity()); + assertTrue(cnxn.getAuthInfo().isEmpty()); + } + @Test public void testLegacyProviderWrapperKeepsExistingStringMatcher() { ServerAuthenticationProvider provider = ProviderRegistry.getServerProvider("ip"); diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java index 896d8437aee..9229b4c0df1 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java @@ -20,6 +20,8 @@ import java.net.InetSocketAddress; import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -31,9 +33,12 @@ import org.apache.zookeeper.ZKUtil; import org.apache.zookeeper.ZooDefs; import org.apache.zookeeper.ZooKeeper; +import org.apache.zookeeper.common.SpiffeAuthTestUtil; +import org.apache.zookeeper.data.ACL; import org.apache.zookeeper.data.Id; import org.apache.zookeeper.server.MockServerCnxn; import org.apache.zookeeper.server.NIOServerCnxnFactory; +import org.apache.zookeeper.server.PrepRequestProcessor; import org.apache.zookeeper.server.ServerCnxn; import org.apache.zookeeper.server.ZooKeeperServer; import org.apache.zookeeper.server.auth.ServerAuthenticationProvider; @@ -72,6 +77,8 @@ public class X509ZNodeGroupAclProviderTest extends ZKTestCase { private static final Map SYSTEM_PROPERTIES = new HashMap<>(); static { SYSTEM_PROPERTIES.put(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, "SuperUser,SuperUser2"); + SYSTEM_PROPERTIES.put(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "false"); + SYSTEM_PROPERTIES.put(X509AuthenticationConfig.SET_X509_CLIENT_ID_AS_ACL, "false"); SYSTEM_PROPERTIES.put("zookeeper.ssl.keyManager", "org.apache.zookeeper.test.X509AuthTest.TestKeyManager"); SYSTEM_PROPERTIES.put("zookeeper.ssl.trustManager", "org.apache.zookeeper.test.X509AuthTest.TestTrustManager"); SYSTEM_PROPERTIES.put(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, X509AuthenticationConfig.SUBJECT_ALTERNATIVE_NAME_SHORT); @@ -198,6 +205,102 @@ public void testSuperUser() { Assert.assertEquals("SuperUser2", authInfo.get(0).getId()); } + @Test + public void testSpiffeSuperUserCompatibilityRequiresOptIn() throws Exception { + String configuredId = "servicePrincipal(kafka"; + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, configuredId); + for (String path : Arrays.asList( + "/v1/wl/kafka", "/v1/application/example-mp/kafka", "/v2/application/example-mp/kafka/blue")) { + String clientId = path.equals("/v1/wl/kafka") ? "kafka" : path.substring("/v1/".length()); + System.clearProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED); + MockServerCnxn normal = authenticateSpiffe(path); + Assert.assertEquals(Collections.singletonList(new Id("x509", clientId)), normal.getAuthInfo()); + + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + MockServerCnxn superUser = authenticateSpiffe(path); + Assert.assertEquals(clientId, superUser.getX509ClientIdentity().getId()); + Assert.assertEquals(Collections.singletonList(new Id("super", configuredId)), superUser.getAuthInfo()); + zks.checkACL(superUser, Collections.singletonList(new ACL(ZooDefs.Perms.READ, new Id("x509", "unrelated"))), + ZooDefs.Perms.ADMIN, superUser.getAuthInfo(), "/protected", null); + } + } + + @Test + public void testExactSuperUserIdWinsBeforeCompatibleMarkers() throws Exception { + String legacyId = "servicePrincipal(zookeeper"; + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, legacyId + ",zookeeper"); + for (String enabled : Arrays.asList("false", "true")) { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, enabled); + Assert.assertEquals(Collections.singletonList(new Id("super", "zookeeper")), + authenticateSpiffe("/v1/wl/zookeeper").getAuthInfo()); + } + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + for (String configuredIds : Arrays.asList(legacyId + ")," + legacyId, legacyId + "," + legacyId + ")")) { + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, configuredIds); + Assert.assertEquals(Collections.singletonList(new Id("super", legacyId)), + authenticateSpiffe("/v1/wl/zookeeper").getAuthInfo()); + } + } + + @Test + public void testSuperUserCompatibilityRejectsOtherIdentityTypes() throws Exception { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, "servicePrincipal(kafka"); + for (String path : Arrays.asList( + "/v2/kafka", "/v2/user/kafka", "/v2/group/kafka", "/v1/airflow/kafka", + "/v2/workload/example-mp/kafka", "/v2/application/kafka", + "/v2/application//kafka", "/v2/application/example-mp/other/kafka")) { + Assert.assertFalse(authenticateSpiffe(path).getAuthInfo().stream() + .anyMatch(id -> id.getScheme().equals("super"))); + } + for (String configuredId : Arrays.asList( + "servicePrincipal(other", "servicePrincipal(Kafka", "userPrincipal(kafka", + "groupPrincipal(kafka", "servicePrincipalMetadata(kafka)")) { + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, configuredId); + Assert.assertFalse(authenticateSpiffe("/v2/application/example-mp/kafka").getAuthInfo().stream() + .anyMatch(id -> id.getScheme().equals("super"))); + } + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, "kafka"); + X509AuthenticationConfig.reset(); + X509AuthTest.TestCertificate legacyCert = new X509AuthTest.TestCertificate("CLIENT", "servicePrincipal(kafka"); + MockServerCnxn legacy = new MockServerCnxn(); + legacy.clientChain = new X509Certificate[]{legacyCert}; + Assert.assertEquals(KeeperException.Code.OK, createProvider(legacyCert).handleAuthentication( + new ServerAuthenticationProvider.ServerObjs(zks, legacy), null)); + Assert.assertEquals(Collections.singletonList(new Id("x509", "servicePrincipal(kafka")), legacy.getAuthInfo()); + } + + @Test + public void testCompatibleSuperUserRetainsExplicitAclPolicy() throws Exception { + String configuredId = "servicePrincipal(kafka"; + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, configuredId); + System.setProperty(X509AuthenticationConfig.SET_X509_CLIENT_ID_AS_ACL, "true"); + admin.create(CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/CrossDomain/" + configuredId, + null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + List requested = Collections.singletonList(new ACL(ZooDefs.Perms.READ, ZooDefs.Ids.ANYONE_ID_UNSAFE)); + + MockServerCnxn crossDomain = authenticateSpiffe("/v2/application/example-mp/kafka"); + Assert.assertEquals(Collections.singletonList(new Id("super", "CrossDomain")), crossDomain.getAuthInfo()); + Assert.assertEquals(Collections.singletonList(new ACL(ZooDefs.Perms.ALL, new Id("x509", "CrossDomain"))), + PrepRequestProcessor.fixupACL("/created", crossDomain.getAuthInfo(), requested)); + + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + MockServerCnxn explicitSuperUser = authenticateSpiffe("/v2/application/example-mp/kafka"); + Assert.assertEquals(Collections.singletonList(new Id("super", configuredId)), explicitSuperUser.getAuthInfo()); + Assert.assertEquals("application/example-mp/kafka", explicitSuperUser.getX509ClientIdentity().getId()); + Assert.assertEquals(requested, PrepRequestProcessor.fixupACL("/created", explicitSuperUser.getAuthInfo(), requested)); + } + + @Test + public void testSuperUserCompatibilityDoesNotUseMappedDomainAsIdentity() throws Exception { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, "servicePrincipal(DomainX"); + admin.create(CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/DomainX/servicePrincipal(kafka", + null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + MockServerCnxn cnxn = authenticateSpiffe("/v2/application/example-mp/kafka"); + Assert.assertEquals(Collections.singletonList(new Id("x509", "DomainX")), cnxn.getAuthInfo()); + } + @Test public void testAuthInfoAutoUpdate() throws InterruptedException, KeeperException { String clientId = "DomainZUser"; @@ -352,6 +455,19 @@ private X509ZNodeGroupAclProvider createProvider(X509Certificate trustedCert) { new X509AuthTest.TestKeyManager()); } + private MockServerCnxn authenticateSpiffe(String path) throws Exception { + SpiffeAuthTestUtil.registerBouncyCastle(); + X509AuthenticationConfig.reset(); + X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans("spiffe://example.org" + path); + X509ZNodeGroupAclProvider provider = new X509ZNodeGroupAclProvider( + new SpiffeAuthTestUtil.AcceptAllTrustManager(), new SpiffeAuthTestUtil.NoopKeyManager()); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{cert}; + Assert.assertEquals(KeeperException.Code.OK, + provider.handleAuthentication(new ServerAuthenticationProvider.ServerObjs(zks, cnxn), null)); + return cnxn; + } + /** * Special ServerCnxnFactory which Exposes the client list for testing auto-refresh AuthInfo. */ @@ -374,4 +490,3 @@ public boolean isClosed() { } } } - From c7dbf32495c5ceb1274fd6e3a558b1f61dc6c270 Mon Sep 17 00:00:00 2001 From: Aditi Bansal Date: Wed, 23 Sep 2026 08:36:10 +0530 Subject: [PATCH 14/16] Support one-way SPIFFE aliases for legacy application IDs Allow eligible SPIFFE application identities to match bare or formatted legacy targets without changing certificate-derived IDs. Restrict new bare aliases to conservative ASCII application names and remove the reverse alias introduced by this branch, preserving original legacy matching. Retain authenticated-ID binding, exact-match precedence and default-off superuser compatibility. Document the boundaries and cover structured targets and legacy exact matches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/resources/markdown/zookeeperAdmin.md | 18 +++- .../auth/LegacyServicePrincipalMatcher.java | 20 +++-- .../server/auth/X509AuthenticationConfig.java | 2 +- .../ZkClientUriDomainMappingHelper.java | 2 +- .../server/auth/X509DirectAclTest.java | 88 +++++++++++++++---- .../X509ZNodeGroupAclProviderTest.java | 80 +++++++++++++---- .../ZkClientUriDomainMappingHelperTest.java | 78 +++++++++++++--- 7 files changed, 230 insertions(+), 58 deletions(-) diff --git a/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md b/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md index a76cc726de9..ebe103528f3 100644 --- a/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md +++ b/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md @@ -1373,9 +1373,9 @@ and [SASL authentication for ZooKeeper](https://cwiki.apache.org/confluence/disp **Default: false.** When enabled, both **X509AuthenticationProvider** and **X509ZNodeGroupAclProvider** can match an authenticated SPIFFE v1/wl identity, or a v1/v2 **application/\/\[/\]** identity, against an - existing legacy service-principal superuser ID by application name. Exact + existing legacy bare-app or service-principal superuser ID by application name. Exact configured client-ID matches take precedence and do not require this option. - Supported legacy forms include **servicePrincipal(kafka**, + Supported legacy forms include **kafka**, **servicePrincipal(kafka**, **servicePrincipal(kafka)**, and **urn:li:servicePrincipal(kafka;region1;instance1)**. The existing superuser settings remain **zookeeper.X509AuthenticationProvider.superUser** and **zookeeper.X509ZNodeGroupAclProvider.superUserId**, respectively. @@ -1391,6 +1391,20 @@ and [SASL authentication for ZooKeeper](https://cwiki.apache.org/confluence/disp setting and restart servers or reconnect clients when changing it; it is not an immediate revocation mechanism for already authenticated connections. + URI-domain and direct ACL compatibility does not require this option. + That compatibility is one-way: eligible SPIFFE application identities may + match bare application names or formatted legacy service principals, but + legacy clients do not acquire reverse aliases. + Original client IDs, exact matches and existing mapped-domain grants remain + unchanged. Bare names are compared literally and must match + `^[A-Za-z0-9][A-Za-z0-9._-]*$`: an ASCII letter or digit followed by letters, + digits, dots, underscores or hyphens. DN/URN-shaped targets are not treated + as bare names, and a full application-path target is not shortened. + This restriction affects only the new bare-name fallback; existing + service-principal parsing and exact legacy SAN/DN matches are unchanged. + A bare ACL ID can also name a domain, so this deliberately permits an eligible + SPIFFE app to use a same-named bare domain ACL. + * *zookeeper.superUser* : (Java system property: **zookeeper.superUser**) Similar to **zookeeper.X509AuthenticationProvider.superUser** diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java index 11faf5c071d..5c2932a5bed 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java @@ -32,6 +32,7 @@ public final class LegacyServicePrincipalMatcher { private static final Pattern LEGACY_SERVICE_PRINCIPAL_PATTERN = Pattern.compile("^(?:urn:li:)?servicePrincipal\\(([^();/]+)(?:\\)|;[^()/]*\\))?$"); + private static final Pattern BARE_APPLICATION_PATTERN = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._-]*$"); private static final Pattern SPIFFE_APPLICATION_PATTERN = Pattern.compile("^application/[^/]+/([^/]+)(?:/[^/]+)?$"); @@ -55,8 +56,8 @@ public static Optional findMatchingSuperUserId(ClientIdentity identity, .findFirst(); } - public static boolean matches(CertificateType certificateType, String clientId, String legacyPrincipal) { - String applicationName = getApplicationName(legacyPrincipal); + public static boolean matches(CertificateType certificateType, String clientId, String legacyId) { + String applicationName = getApplicationName(legacyId); if (applicationName == null || clientId == null) { return false; } @@ -77,16 +78,17 @@ public static boolean matchesAuthenticatedClient(ServerCnxn cnxn, String authent // Bind the candidate AuthInfo ID to the authenticated certificate identity, not a mapped domain. ClientIdentity identity = cnxn.getX509ClientIdentity(); return identity != null && identity.getId().equals(authenticatedId) - && (matches(identity.getCertificateType(), authenticatedId, aclId) - || (identity.getCertificateType() == CertificateType.LEGACY_SAN - && aclId != null && aclId.equals(getApplicationName(authenticatedId)))); + && matches(identity.getCertificateType(), authenticatedId, aclId); } - private static String getApplicationName(String legacyPrincipal) { - if (legacyPrincipal == null) { + private static String getApplicationName(String legacyId) { + if (legacyId == null) { return null; } - Matcher matcher = LEGACY_SERVICE_PRINCIPAL_PATTERN.matcher(legacyPrincipal); - return matcher.matches() ? matcher.group(1) : null; + Matcher matcher = LEGACY_SERVICE_PRINCIPAL_PATTERN.matcher(legacyId); + if (matcher.matches()) { + return matcher.group(1); + } + return BARE_APPLICATION_PATTERN.matcher(legacyId).matches() ? legacyId : null; } } 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 6a0a019c934..6538b004252 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 @@ -65,7 +65,7 @@ public static X509AuthenticationConfig getInstance() { */ public static final String SSL_X509_CONFIG_PREFIX = "zookeeper.ssl.x509."; /** - * Opt-in matching of SPIFFE application names against legacy service-principal superuser IDs. + * Opt-in matching of SPIFFE application names against legacy bare-app or service-principal superuser IDs. * Disabled by default; applies to both providers and does not distinguish products or tags. */ public static final String SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED = 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 85f22f99557..08ce94c2a5f 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 @@ -206,7 +206,7 @@ public Set getDomains(String clientUri) { /** * After exact and segment-prefix lookup miss, SPIFFE v1/wl and v1/v2 application identities - * may match the application name in a legacy service-principal znode. Application paths must + * may match a legacy bare-app or service-principal mapping key. Application paths must * contain both MP and app segments, with an optional tag; other principal kinds are not aliases. */ @Override diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java index 07778ca37b9..9fd73568e18 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java @@ -120,7 +120,7 @@ public void testApplicationIdentitiesMatchLegacyAclsWithoutChangingFullIdentity( String clientId = path.substring("/v1/".length()); assertEquals(Collections.singletonList(new Id("x509", clientId)), cnxn.getAuthInfo()); for (String id : Arrays.asList( - clientId, "servicePrincipal(kafka", "servicePrincipal(kafka)", + clientId, "kafka", "servicePrincipal(kafka", "servicePrincipal(kafka)", "urn:li:servicePrincipal(kafka;region1;instance1)")) { server.checkACL(cnxn, acl(id, ZooDefs.Perms.READ), ZooDefs.Perms.READ, cnxn.getAuthInfo(), "/protected", null); @@ -128,18 +128,57 @@ public void testApplicationIdentitiesMatchLegacyAclsWithoutChangingFullIdentity( assertEquals(Collections.singletonList(new ACL(ZooDefs.Perms.ALL, new Id("x509", clientId))), PrepRequestProcessor.fixupACL("/created", cnxn.getAuthInfo(), ZooDefs.Ids.CREATOR_ALL_ACL)); assertDenied(cnxn, "servicePrincipal(kafka", ZooDefs.Perms.READ, ZooDefs.Perms.WRITE); + assertDenied(cnxn, "kafka", ZooDefs.Perms.READ, ZooDefs.Perms.WRITE); for (String id : Arrays.asList( - "kafka", "servicePrincipal(example-mp", "servicePrincipal(cluster-a", + "other", "kafka-extra", "Kafka", "kafka)", "kafka;instance", "nested/kafka", + "application/other-mp/kafka", "servicePrincipal(example-mp", "servicePrincipal(cluster-a", "servicePrincipal(kafka-extra", "servicePrincipal(Kafka", "userPrincipal(kafka", "groupPrincipal(kafka", "servicePrincipalMetadata(kafka)")) { assertDenied(cnxn, id, ZooDefs.Perms.ALL, ZooDefs.Perms.READ); } cnxn.addAuthInfo(new Id("x509", "application/other-mp/other-app")); assertDenied(cnxn, "servicePrincipal(other-app", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + assertDenied(cnxn, "other-app", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); } } } + @Test + public void testBareApplicationNameGrammarPreservesFormattedMatching() throws Exception { + for (String app : Arrays.asList("kafka-server", "kafka_1", "kafka.v2", "Kafka", "9kafka")) { + MockServerCnxn cnxn = authenticate("spiffe://example.org/v2/application/example-mp/" + app); + server.checkACL(cnxn, acl(app, ZooDefs.Perms.READ), ZooDefs.Perms.READ, + cnxn.getAuthInfo(), "/protected", null); + } + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + for (String target : Arrays.asList( + "CN=admin", "urn:example:admin", "CN=admin,O=example", "kafka+worker", "kafka@realm", + "_kafka", "-kafka", ".kafka")) { + System.setProperty(SUPERUSER_PROPERTY, target); + String clientId = "application/example-mp/" + target; + MockServerCnxn cnxn = authenticate("spiffe://example.org/v2/" + clientId); + assertEquals(clientId, cnxn.getX509ClientIdentity().getId()); + assertEquals(Collections.singletonList(new Id("x509", clientId)), cnxn.getAuthInfo()); + assertDenied(cnxn, target, ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + server.checkACL(cnxn, acl("servicePrincipal(" + target, ZooDefs.Perms.READ), ZooDefs.Perms.READ, + cnxn.getAuthInfo(), "/protected", null); + } + } + + @Test + public void testStructuredSuperUserIdsKeepExactLegacyMatches() throws Exception { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + System.setProperty(SUPERUSER_PROPERTY, "CN=test-client"); + MockServerCnxn subject = authenticate("urn:example:admin"); + assertTrue(subject.getAuthInfo().contains(new Id("super", "CN=test-client"))); + + configureSan("^(urn:example:admin)$"); + System.setProperty(SUPERUSER_PROPERTY, "urn:example:admin"); + MockServerCnxn legacy = authenticate("urn:example:admin"); + assertEquals("urn:example:admin", legacy.getX509ClientIdentity().getId()); + assertTrue(legacy.getAuthInfo().contains(new Id("super", "urn:example:admin"))); + } + @Test public void testAclPermissionAndExactApplicationNameAreStillRequired() throws Exception { MockServerCnxn cnxn = authenticate("spiffe://example.org/v1/wl/kafka"); @@ -165,6 +204,12 @@ public void testOtherSpiffeTypesDoNotGainLegacyServiceAccess() throws Exception "/v2/%75ser/kafka", "/v1/wl/kafka/extra")) { MockServerCnxn cnxn = authenticate("spiffe://example.org" + path); assertDenied(cnxn, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + if ("kafka".equals(cnxn.getX509ClientIdentity().getId())) { + server.checkACL(cnxn, acl("kafka", ZooDefs.Perms.READ), ZooDefs.Perms.READ, + cnxn.getAuthInfo(), "/protected", null); + } else { + assertDenied(cnxn, "kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } } } @@ -177,6 +222,9 @@ public void testConfiguredSanWithSameNameDoesNotGainSpiffeCompatibility() throws server.checkACL(cnxn, acl(id, ZooDefs.Perms.READ), ZooDefs.Perms.READ, cnxn.getAuthInfo(), "/protected", null); assertDenied(cnxn, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + if (!id.equals("kafka")) { + assertDenied(cnxn, "kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } } } @@ -192,18 +240,18 @@ public void testSubjectDnAndLegacySanExactAclsRemainValid() throws Exception { assertEquals(Collections.singletonList(new Id("x509", "servicePrincipal(kafka")), legacy.getAuthInfo()); server.checkACL(legacy, acl("servicePrincipal(kafka", ZooDefs.Perms.READ), ZooDefs.Perms.READ, legacy.getAuthInfo(), "/protected", null); - server.checkACL(legacy, acl("kafka", ZooDefs.Perms.READ), ZooDefs.Perms.READ, - legacy.getAuthInfo(), "/protected", null); + assertDenied(legacy, "kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); assertDenied(legacy, "servicePrincipal(other", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); - assertDenied(legacy, "kafka", ZooDefs.Perms.READ, ZooDefs.Perms.WRITE); + assertDenied(legacy, "servicePrincipal(kafka", ZooDefs.Perms.READ, ZooDefs.Perms.WRITE); assertDenied(legacy, "application/example-mp/kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); assertDenied(legacy, "application/example-mp/kafka/cluster-a", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); } @Test - public void testReverseCompatibilityRequiresLegacyServiceIdentity() throws Exception { + public void testLegacyIdentitiesDoNotGainReverseCompatibility() throws Exception { configureSan("^urn:li:([^;]+)"); - for (String kind : Arrays.asList("userPrincipal", "groupPrincipal", "servicePrincipalMetadata")) { + for (String kind : Arrays.asList( + "servicePrincipal", "userPrincipal", "groupPrincipal", "servicePrincipalMetadata")) { MockServerCnxn cnxn = authenticate("urn:li:" + kind + "(kafka;region1;instance1)"); assertDenied(cnxn, "kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); } @@ -229,8 +277,7 @@ public void testGroupProviderCannotTreatMappedDomainAsCertificateIdentity() thro configureSan("^urn:li:(servicePrincipal\\([^;]+)"); MockServerCnxn legacy = authenticate("urn:li:servicePrincipal(kafka;region1;instance1)"); legacy.addAuthInfo(new Id("x509", "servicePrincipal(other")); - server.checkACL(legacy, acl("kafka", ZooDefs.Perms.READ), ZooDefs.Perms.READ, - legacy.getAuthInfo(), "/protected", null); + assertDenied(legacy, "kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); assertDenied(legacy, "other", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); } @@ -251,10 +298,11 @@ public Certificate[] getClientCertificateChain() { MockServerCnxn certificateOnly = new MockServerCnxn(); certificateOnly.clientChain = new X509Certificate[]{ - SpiffeAuthTestUtil.buildClientCertWithUriSans("spiffe://example.org/v1/wl/kafka") + SpiffeAuthTestUtil.buildClientCertWithUriSans("spiffe://example.org/v2/application/example-mp/kafka") }; - certificateOnly.addAuthInfo(new Id("x509", "kafka")); + certificateOnly.addAuthInfo(new Id("x509", "application/example-mp/kafka")); assertDenied(certificateOnly, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + assertDenied(certificateOnly, "kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); } @Test @@ -282,7 +330,7 @@ public void testCompatibilityDoesNotPromoteConfiguredSuperuserAlias() throws Exc public void testOptInSuperUserCompatibilityPreservesOriginalIdentity() throws Exception { System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); for (String configuredId : Arrays.asList( - "servicePrincipal(kafka", "servicePrincipal(kafka)", + "kafka", "servicePrincipal(kafka", "servicePrincipal(kafka)", "urn:li:servicePrincipal(kafka;region1;instance1)")) { System.setProperty(SUPERUSER_PROPERTY, configuredId); for (String path : Arrays.asList( @@ -306,9 +354,11 @@ public void testOptInSuperUserCompatibilityPreservesOriginalIdentity() throws Ex @Test public void testExactSuperUserDoesNotRequireCompatibility() throws Exception { System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "false"); - System.setProperty(SUPERUSER_PROPERTY, "servicePrincipal(kafka"); - MockServerCnxn normal = authenticate("spiffe://example.org/v2/application/example-mp/kafka"); - assertDenied(normal, "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + for (String configuredId : Arrays.asList("kafka", "servicePrincipal(kafka")) { + System.setProperty(SUPERUSER_PROPERTY, configuredId); + MockServerCnxn normal = authenticate("spiffe://example.org/v2/application/example-mp/kafka"); + assertDenied(normal, "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } System.setProperty(SUPERUSER_PROPERTY, "application/example-mp/kafka"); MockServerCnxn exact = authenticate("spiffe://example.org/v2/application/example-mp/kafka"); @@ -333,16 +383,20 @@ public void testSuperUserCompatibilityRejectsOtherIdentityTypes() throws Excepti for (String id : Arrays.asList("kafka", "application/example-mp/kafka")) { assertDenied(authenticate("urn:example:" + id), "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); } - configureSan("^urn:li:(servicePrincipal\\([^;]+)"); System.setProperty(SUPERUSER_PROPERTY, "kafka"); + assertDenied(authenticate("urn:example:application/example-mp/kafka"), + "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + configureSan("^urn:li:(servicePrincipal\\([^;]+)"); assertDenied(authenticate("urn:li:servicePrincipal(kafka;region1;instance1)"), "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); } @Test - public void testSuperUserCompatibilityRequiresMatchingServicePrincipalConfig() throws Exception { + public void testSuperUserCompatibilityRequiresMatchingLegacyConfig() throws Exception { System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); for (String configuredId : Arrays.asList( + "other", "kafka-extra", "Kafka", "kafka)", "kafka;instance", "nested/kafka", + "application/other-mp/kafka", "servicePrincipal(other", "servicePrincipal(kafka-extra", "servicePrincipal(Kafka", "userPrincipal(kafka", "groupPrincipal(kafka", "servicePrincipalMetadata(kafka)", "servicePrincipal(kafka)extra")) { diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java index 9229b4c0df1..25f2dc9e5ba 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java @@ -225,14 +225,34 @@ public void testSpiffeSuperUserCompatibilityRequiresOptIn() throws Exception { } } + @Test + public void testBareSuperUserConfigurationSupportsSpiffeApplications() throws Exception { + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, "kafka"); + for (String path : Arrays.asList( + "/v1/application/example-mp/kafka", "/v2/application/example-mp/kafka/blue")) { + String clientId = path.substring("/v1/".length()); + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "false"); + Assert.assertEquals(Collections.singletonList(new Id("x509", clientId)), authenticateSpiffe(path).getAuthInfo()); + + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + MockServerCnxn superUser = authenticateSpiffe(path); + Assert.assertEquals(Collections.singletonList(new Id("super", "kafka")), superUser.getAuthInfo()); + Assert.assertEquals(clientId, superUser.getX509ClientIdentity().getId()); + } + } + @Test public void testExactSuperUserIdWinsBeforeCompatibleMarkers() throws Exception { String legacyId = "servicePrincipal(zookeeper"; - System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, legacyId + ",zookeeper"); + String applicationId = "application/example-mp/zookeeper"; + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, + legacyId + ",zookeeper," + applicationId); for (String enabled : Arrays.asList("false", "true")) { System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, enabled); Assert.assertEquals(Collections.singletonList(new Id("super", "zookeeper")), authenticateSpiffe("/v1/wl/zookeeper").getAuthInfo()); + Assert.assertEquals(Collections.singletonList(new Id("super", applicationId)), + authenticateSpiffe("/v2/" + applicationId).getAuthInfo()); } System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); for (String configuredIds : Arrays.asList(legacyId + ")," + legacyId, legacyId + "," + legacyId + ")")) { @@ -254,12 +274,19 @@ public void testSuperUserCompatibilityRejectsOtherIdentityTypes() throws Excepti .anyMatch(id -> id.getScheme().equals("super"))); } for (String configuredId : Arrays.asList( + "other", "Kafka", "kafka)", "kafka;instance", "application/other-mp/kafka", "servicePrincipal(other", "servicePrincipal(Kafka", "userPrincipal(kafka", "groupPrincipal(kafka", "servicePrincipalMetadata(kafka)")) { System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, configuredId); Assert.assertFalse(authenticateSpiffe("/v2/application/example-mp/kafka").getAuthInfo().stream() .anyMatch(id -> id.getScheme().equals("super"))); } + for (String structuredId : Arrays.asList("CN=admin", "urn:example:admin", "_kafka", "-kafka", ".kafka")) { + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, structuredId); + MockServerCnxn cnxn = authenticateSpiffe("/v2/application/example-mp/" + structuredId); + Assert.assertEquals("application/example-mp/" + structuredId, cnxn.getX509ClientIdentity().getId()); + Assert.assertFalse(cnxn.getAuthInfo().stream().anyMatch(id -> id.getScheme().equals("super"))); + } System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, "kafka"); X509AuthenticationConfig.reset(); X509AuthTest.TestCertificate legacyCert = new X509AuthTest.TestCertificate("CLIENT", "servicePrincipal(kafka"); @@ -270,35 +297,54 @@ public void testSuperUserCompatibilityRejectsOtherIdentityTypes() throws Excepti Assert.assertEquals(Collections.singletonList(new Id("x509", "servicePrincipal(kafka")), legacy.getAuthInfo()); } + @Test + public void testStructuredSuperUserIdsKeepExactLegacyMatches() throws Exception { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + for (String clientId : Arrays.asList("CN=admin", "urn:example:admin")) { + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, clientId); + X509AuthenticationConfig.reset(); + X509AuthTest.TestCertificate cert = new X509AuthTest.TestCertificate("CLIENT", clientId); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{cert}; + Assert.assertEquals(KeeperException.Code.OK, createProvider(cert).handleAuthentication( + new ServerAuthenticationProvider.ServerObjs(zks, cnxn), null)); + Assert.assertEquals(Collections.singletonList(new Id("super", clientId)), cnxn.getAuthInfo()); + } + } + @Test public void testCompatibleSuperUserRetainsExplicitAclPolicy() throws Exception { - String configuredId = "servicePrincipal(kafka"; - System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, configuredId); System.setProperty(X509AuthenticationConfig.SET_X509_CLIENT_ID_AS_ACL, "true"); - admin.create(CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/CrossDomain/" + configuredId, - null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); List requested = Collections.singletonList(new ACL(ZooDefs.Perms.READ, ZooDefs.Ids.ANYONE_ID_UNSAFE)); + for (String configuredId : Arrays.asList("kafka", "servicePrincipal(kafka")) { + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, configuredId); + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "false"); + admin.create(CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/CrossDomain/" + configuredId, + null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); - MockServerCnxn crossDomain = authenticateSpiffe("/v2/application/example-mp/kafka"); - Assert.assertEquals(Collections.singletonList(new Id("super", "CrossDomain")), crossDomain.getAuthInfo()); - Assert.assertEquals(Collections.singletonList(new ACL(ZooDefs.Perms.ALL, new Id("x509", "CrossDomain"))), - PrepRequestProcessor.fixupACL("/created", crossDomain.getAuthInfo(), requested)); + MockServerCnxn crossDomain = authenticateSpiffe("/v2/application/example-mp/kafka"); + Assert.assertEquals(Collections.singletonList(new Id("super", "CrossDomain")), crossDomain.getAuthInfo()); + Assert.assertEquals(Collections.singletonList(new ACL(ZooDefs.Perms.ALL, new Id("x509", "CrossDomain"))), + PrepRequestProcessor.fixupACL("/created", crossDomain.getAuthInfo(), requested)); - System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); - MockServerCnxn explicitSuperUser = authenticateSpiffe("/v2/application/example-mp/kafka"); - Assert.assertEquals(Collections.singletonList(new Id("super", configuredId)), explicitSuperUser.getAuthInfo()); - Assert.assertEquals("application/example-mp/kafka", explicitSuperUser.getX509ClientIdentity().getId()); - Assert.assertEquals(requested, PrepRequestProcessor.fixupACL("/created", explicitSuperUser.getAuthInfo(), requested)); + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + MockServerCnxn explicitSuperUser = authenticateSpiffe("/v2/application/example-mp/kafka"); + Assert.assertEquals(Collections.singletonList(new Id("super", configuredId)), explicitSuperUser.getAuthInfo()); + Assert.assertEquals("application/example-mp/kafka", explicitSuperUser.getX509ClientIdentity().getId()); + Assert.assertEquals(requested, PrepRequestProcessor.fixupACL("/created", explicitSuperUser.getAuthInfo(), requested)); + } } @Test public void testSuperUserCompatibilityDoesNotUseMappedDomainAsIdentity() throws Exception { System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); - System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, "servicePrincipal(DomainX"); admin.create(CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/DomainX/servicePrincipal(kafka", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); - MockServerCnxn cnxn = authenticateSpiffe("/v2/application/example-mp/kafka"); - Assert.assertEquals(Collections.singletonList(new Id("x509", "DomainX")), cnxn.getAuthInfo()); + for (String configuredId : Arrays.asList("DomainX", "servicePrincipal(DomainX")) { + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, configuredId); + MockServerCnxn cnxn = authenticateSpiffe("/v2/application/example-mp/kafka"); + Assert.assertEquals(Collections.singletonList(new Id("x509", "DomainX")), cnxn.getAuthInfo()); + } } @Test 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 7191f8e086e..ac08297a7f9 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 @@ -85,7 +85,8 @@ public class ZkClientUriDomainMappingHelperTest extends ZKTestCase { CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp-grant/application", CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp-grant/application/helix-core", CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access", - CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/servicePrincipal(kafka" + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/servicePrincipal(kafka", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/kafka" }; private ZooKeeperServer zookeeperServer; @@ -414,6 +415,31 @@ public void testA6_LegacySanStillUsesOriginalMappingKey() throws Exception { } } + @Test + public void testA7_SpiffeApplicationUsesBareLegacyMapping() throws Exception { + String mappingPath = CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/kafka"; + for (String path : Arrays.asList( + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH, CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access", mappingPath)) { + zookeeperClientConnection.create(path, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + } + String clientId = "application/example-mp/kafka"; + X509ZNodeGroupAclProvider provider = new X509ZNodeGroupAclProvider( + new SpiffeAuthTestUtil.AcceptAllTrustManager(), new SpiffeAuthTestUtil.NoopKeyManager()); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{ + SpiffeAuthTestUtil.buildClientCertWithUriSans("spiffe://example.org/v2/" + clientId) + }; + ServerAuthenticationProvider.ServerObjs serverObjs = + new ServerAuthenticationProvider.ServerObjs(zookeeperServer, cnxn); + Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); + Assert.assertEquals(Collections.singletonList(new Id("x509", "broker-access")), cnxn.getAuthInfo()); + Assert.assertEquals(clientId, cnxn.getX509ClientIdentity().getId()); + + zookeeperClientConnection.delete(mappingPath, -1); + Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); + Assert.assertEquals(Collections.singletonList(new Id("x509", clientId)), cnxn.getAuthInfo()); + } + @Test /** * Make sure the watcher installed while instantiate ZkClientUriDomainMappingHelper does not break @@ -494,7 +520,7 @@ public void testC_GetDomainsNullClientUri() { } @Test - public void testD_SpiffeWorkloadMatchesLegacyServicePrincipalNames() { + public void testD_SpiffeWorkloadMatchesLegacyApplicationNames() { ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); Map> mapping = new HashMap<>(); mapping.put("servicePrincipal(kafka", Collections.singleton("truncated-domain")); @@ -504,10 +530,14 @@ public void testD_SpiffeWorkloadMatchesLegacyServicePrincipalNames() { Assert.assertEquals(new HashSet<>(Arrays.asList("truncated-domain", "closed-domain", "urn-domain")), helper.getDomains(CertificateType.SPIFFE_V1_WL, "kafka")); + mapping.put("kafka", Collections.singleton("bare-domain")); + Assert.assertEquals(Collections.singleton("bare-domain"), + helper.getDomains(CertificateType.SPIFFE_V1_WL, "kafka")); for (CertificateType type : Arrays.asList( CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { for (String clientId : Arrays.asList("application/example-mp/kafka", "application/example-mp/kafka/cluster-a")) { - Assert.assertEquals(new HashSet<>(Arrays.asList("truncated-domain", "closed-domain", "urn-domain")), + Assert.assertEquals(new HashSet<>(Arrays.asList( + "bare-domain", "truncated-domain", "closed-domain", "urn-domain")), helper.getDomains(type, clientId)); } } @@ -555,6 +585,7 @@ public void testD_TypedMultiSegmentLookupRetainsPrefixMatching() { ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); Map> mapping = new HashMap<>(); mapping.put("servicePrincipal(kafka", Collections.singleton("legacy-domain")); + mapping.put("kafka", Collections.singleton("bare-domain")); mapping.put("application/example-mp", Collections.singleton("path-domain")); setMapping(helper, mapping); @@ -562,7 +593,7 @@ public void testD_TypedMultiSegmentLookupRetainsPrefixMatching() { CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { Assert.assertEquals(Collections.singleton("path-domain"), helper.getDomains(type, "application/example-mp/kafka")); - Assert.assertEquals(Collections.singleton("legacy-domain"), + Assert.assertEquals(new HashSet<>(Arrays.asList("bare-domain", "legacy-domain")), helper.getDomains(type, "application/unrelated-mp/kafka")); Assert.assertEquals(Collections.emptySet(), helper.getDomains(type, "group/kafka")); } @@ -579,6 +610,7 @@ public void testD_EmptyPrefixMappingDoesNotFallBackToLegacy() { ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); Map> mapping = new HashMap<>(); mapping.put("servicePrincipal(kafka", Collections.singleton("legacy-domain")); + mapping.put("kafka", Collections.singleton("bare-domain")); mapping.put("application/example-mp", Collections.emptySet()); setMapping(helper, mapping); @@ -591,7 +623,10 @@ public void testD_EmptyPrefixMappingDoesNotFallBackToLegacy() { @Test public void testD_ApplicationAliasesRequireCompleteApplicationPath() { ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); - setMapping(helper, Collections.singletonMap("servicePrincipal(kafka", Collections.singleton("broker-access"))); + Map> mapping = new HashMap<>(); + mapping.put("servicePrincipal(kafka", Collections.singleton("broker-access")); + mapping.put("kafka", Collections.singleton("broker-access")); + setMapping(helper, mapping); for (CertificateType type : Arrays.asList( CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { @@ -611,14 +646,33 @@ public void testD_ApplicationAliasesRequireCompleteApplicationPath() { @Test public void testD_ApplicationAliasesRequireSpiffeCertificateType() { ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); - setMapping(helper, Collections.singletonMap("servicePrincipal(kafka", Collections.singleton("broker-access"))); - for (CertificateType type : Arrays.asList( - CertificateType.SPIFFE_V1_WL, CertificateType.LEGACY_SAN, CertificateType.SUBJECT_DN)) { - Assert.assertEquals(type.name(), Collections.emptySet(), - helper.getDomains(type, "application/example-mp/kafka")); + for (String legacyId : Arrays.asList("kafka", "servicePrincipal(kafka")) { + setMapping(helper, Collections.singletonMap(legacyId, Collections.singleton("broker-access"))); + for (CertificateType type : Arrays.asList( + CertificateType.SPIFFE_V1_WL, CertificateType.LEGACY_SAN, CertificateType.SUBJECT_DN)) { + Assert.assertEquals(type.name(), Collections.emptySet(), + helper.getDomains(type, "application/example-mp/kafka")); + } + Assert.assertEquals(Collections.emptySet(), helper.getDomains("application/example-mp/kafka")); + Assert.assertEquals(Collections.emptySet(), helper.getDomains(CertificateType.LEGACY_SAN, "servicePrincipal(other")); + } + } + + @Test + public void testD_BareNameGrammarDoesNotChangeExactLegacyMappings() { + ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); + for (String target : Arrays.asList( + "CN=admin", "urn:example:admin", "CN=admin,O=example", "kafka+worker", "kafka@realm", + "_kafka", "-kafka", ".kafka")) { + setMapping(helper, Collections.singletonMap(target, Collections.singleton("legacy-domain"))); + for (CertificateType type : Arrays.asList( + CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { + Assert.assertEquals(Collections.emptySet(), helper.getDomains(type, "application/example-mp/" + target)); + } + Assert.assertEquals(Collections.singleton("legacy-domain"), helper.getDomains(CertificateType.LEGACY_SAN, target)); + Assert.assertEquals(Collections.singleton("legacy-domain"), helper.getDomains(target)); } - Assert.assertEquals(Collections.emptySet(), helper.getDomains("application/example-mp/kafka")); } @Test @@ -626,6 +680,8 @@ public void testD_OtherPrincipalKindsAndMalformedNamesAreNotAliases() { ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); Map> mapping = new HashMap<>(); for (String name : Arrays.asList( + "other", "kafka-extra", "Kafka", "kafka)", "kafka;instance", "nested/kafka", + "application/other-mp/kafka", "userPrincipal(kafka", "groupPrincipal(kafka", "servicePrincipalMetadata(kafka)", From 75a4bf48a687a8a5fd21de50a79fa47e9d32e9c5 Mon Sep 17 00:00:00 2001 From: Aditi Bansal Date: Mon, 28 Sep 2026 08:12:24 +0530 Subject: [PATCH 15/16] Limit SPIFFE compatibility to formatted legacy service principals Remove bare application-name fallback from shared ACL, domain mapping and opt-in superuser matching. Preserve exact client IDs, existing domain grants and formatted service-principal aliases. Update the coverage and documentation to distinguish compatibility from existing exact matches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/resources/markdown/zookeeperAdmin.md | 20 +++++----- .../auth/LegacyServicePrincipalMatcher.java | 6 +-- .../server/auth/X509AuthenticationConfig.java | 2 +- .../ZkClientUriDomainMappingHelper.java | 2 +- .../server/auth/X509DirectAclTest.java | 37 ++++++++++++++----- .../X509ZNodeGroupAclProviderTest.java | 32 ++++++++++------ .../ZkClientUriDomainMappingHelperTest.java | 19 +++++++--- 7 files changed, 73 insertions(+), 45 deletions(-) diff --git a/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md b/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md index ebe103528f3..999101f58eb 100644 --- a/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md +++ b/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md @@ -1373,9 +1373,9 @@ and [SASL authentication for ZooKeeper](https://cwiki.apache.org/confluence/disp **Default: false.** When enabled, both **X509AuthenticationProvider** and **X509ZNodeGroupAclProvider** can match an authenticated SPIFFE v1/wl identity, or a v1/v2 **application/\/\[/\]** identity, against an - existing legacy bare-app or service-principal superuser ID by application name. Exact + existing formatted legacy service-principal superuser ID by application name. Exact configured client-ID matches take precedence and do not require this option. - Supported legacy forms include **kafka**, **servicePrincipal(kafka**, + Supported legacy forms include **servicePrincipal(kafka**, **servicePrincipal(kafka)**, and **urn:li:servicePrincipal(kafka;region1;instance1)**. The existing superuser settings remain **zookeeper.X509AuthenticationProvider.superUser** and **zookeeper.X509ZNodeGroupAclProvider.superUserId**, respectively. @@ -1393,17 +1393,15 @@ and [SASL authentication for ZooKeeper](https://cwiki.apache.org/confluence/disp URI-domain and direct ACL compatibility does not require this option. That compatibility is one-way: eligible SPIFFE application identities may - match bare application names or formatted legacy service principals, but + match formatted legacy service principals, but legacy clients do not acquire reverse aliases. Original client IDs, exact matches and existing mapped-domain grants remain - unchanged. Bare names are compared literally and must match - `^[A-Za-z0-9][A-Za-z0-9._-]*$`: an ASCII letter or digit followed by letters, - digits, dots, underscores or hyphens. DN/URN-shaped targets are not treated - as bare names, and a full application-path target is not shortened. - This restriction affects only the new bare-name fallback; existing - service-principal parsing and exact legacy SAN/DN matches are unchanged. - A bare ACL ID can also name a domain, so this deliberately permits an eligible - SPIFFE app to use a same-named bare domain ACL. + unchanged. Bare names such as **kafka** are not compatibility targets for + URI-domain mappings, direct ACLs or superuser selection. For example, + **application/example-mp/kafka** does not gain an alias to **kafka**. + This does not reject existing exact matches: a v1/wl or legacy SAN identity + extracted as **kafka** still matches that exact ID. A client explicitly + mapped into domain **kafka** can still match the **x509:kafka** domain ACL. * *zookeeper.superUser* : (Java system property: **zookeeper.superUser**) diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java index 5c2932a5bed..802aad038e4 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java @@ -32,7 +32,6 @@ public final class LegacyServicePrincipalMatcher { private static final Pattern LEGACY_SERVICE_PRINCIPAL_PATTERN = Pattern.compile("^(?:urn:li:)?servicePrincipal\\(([^();/]+)(?:\\)|;[^()/]*\\))?$"); - private static final Pattern BARE_APPLICATION_PATTERN = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._-]*$"); private static final Pattern SPIFFE_APPLICATION_PATTERN = Pattern.compile("^application/[^/]+/([^/]+)(?:/[^/]+)?$"); @@ -86,9 +85,6 @@ private static String getApplicationName(String legacyId) { return null; } Matcher matcher = LEGACY_SERVICE_PRINCIPAL_PATTERN.matcher(legacyId); - if (matcher.matches()) { - return matcher.group(1); - } - return BARE_APPLICATION_PATTERN.matcher(legacyId).matches() ? legacyId : null; + return matcher.matches() ? matcher.group(1) : null; } } 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 6538b004252..981e9d2638b 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 @@ -65,7 +65,7 @@ public static X509AuthenticationConfig getInstance() { */ public static final String SSL_X509_CONFIG_PREFIX = "zookeeper.ssl.x509."; /** - * Opt-in matching of SPIFFE application names against legacy bare-app or service-principal superuser IDs. + * Opt-in matching of SPIFFE application names against formatted legacy service-principal superuser IDs. * Disabled by default; applies to both providers and does not distinguish products or tags. */ public static final String SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED = 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 08ce94c2a5f..ec0fceaf38b 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 @@ -206,7 +206,7 @@ public Set getDomains(String clientUri) { /** * After exact and segment-prefix lookup miss, SPIFFE v1/wl and v1/v2 application identities - * may match a legacy bare-app or service-principal mapping key. Application paths must + * may match a formatted legacy service-principal mapping key. Application paths must * contain both MP and app segments, with an optional tag; other principal kinds are not aliases. */ @Override diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java index 9fd73568e18..fce8b47fc93 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java @@ -120,7 +120,7 @@ public void testApplicationIdentitiesMatchLegacyAclsWithoutChangingFullIdentity( String clientId = path.substring("/v1/".length()); assertEquals(Collections.singletonList(new Id("x509", clientId)), cnxn.getAuthInfo()); for (String id : Arrays.asList( - clientId, "kafka", "servicePrincipal(kafka", "servicePrincipal(kafka)", + clientId, "servicePrincipal(kafka", "servicePrincipal(kafka)", "urn:li:servicePrincipal(kafka;region1;instance1)")) { server.checkACL(cnxn, acl(id, ZooDefs.Perms.READ), ZooDefs.Perms.READ, cnxn.getAuthInfo(), "/protected", null); @@ -130,7 +130,7 @@ public void testApplicationIdentitiesMatchLegacyAclsWithoutChangingFullIdentity( assertDenied(cnxn, "servicePrincipal(kafka", ZooDefs.Perms.READ, ZooDefs.Perms.WRITE); assertDenied(cnxn, "kafka", ZooDefs.Perms.READ, ZooDefs.Perms.WRITE); for (String id : Arrays.asList( - "other", "kafka-extra", "Kafka", "kafka)", "kafka;instance", "nested/kafka", + "kafka", "other", "kafka-extra", "Kafka", "kafka)", "kafka;instance", "nested/kafka", "application/other-mp/kafka", "servicePrincipal(example-mp", "servicePrincipal(cluster-a", "servicePrincipal(kafka-extra", "servicePrincipal(Kafka", "userPrincipal(kafka", "groupPrincipal(kafka", "servicePrincipalMetadata(kafka)")) { @@ -144,14 +144,10 @@ public void testApplicationIdentitiesMatchLegacyAclsWithoutChangingFullIdentity( } @Test - public void testBareApplicationNameGrammarPreservesFormattedMatching() throws Exception { - for (String app : Arrays.asList("kafka-server", "kafka_1", "kafka.v2", "Kafka", "9kafka")) { - MockServerCnxn cnxn = authenticate("spiffe://example.org/v2/application/example-mp/" + app); - server.checkACL(cnxn, acl(app, ZooDefs.Perms.READ), ZooDefs.Perms.READ, - cnxn.getAuthInfo(), "/protected", null); - } + public void testUnformattedTargetsDoNotGainApplicationCompatibility() throws Exception { System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); for (String target : Arrays.asList( + "kafka", "kafka-server", "kafka_1", "kafka.v2", "Kafka", "9kafka", "CN=admin", "urn:example:admin", "CN=admin,O=example", "kafka+worker", "kafka@realm", "_kafka", "-kafka", ".kafka")) { System.setProperty(SUPERUSER_PROPERTY, target); @@ -330,7 +326,7 @@ public void testCompatibilityDoesNotPromoteConfiguredSuperuserAlias() throws Exc public void testOptInSuperUserCompatibilityPreservesOriginalIdentity() throws Exception { System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); for (String configuredId : Arrays.asList( - "kafka", "servicePrincipal(kafka", "servicePrincipal(kafka)", + "servicePrincipal(kafka", "servicePrincipal(kafka)", "urn:li:servicePrincipal(kafka;region1;instance1)")) { System.setProperty(SUPERUSER_PROPERTY, configuredId); for (String path : Arrays.asList( @@ -351,6 +347,27 @@ public void testOptInSuperUserCompatibilityPreservesOriginalIdentity() throws Ex } } + @Test + public void testBareSuperUserIdRequiresExactIdentity() throws Exception { + System.setProperty(SUPERUSER_PROPERTY, "kafka"); + configureSan("^urn:example:(.*)$"); + for (String enabled : Arrays.asList("false", "true")) { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, enabled); + for (String path : Arrays.asList( + "/v1/application/example-mp/kafka", "/v2/application/example-mp/kafka/blue")) { + MockServerCnxn cnxn = authenticate("spiffe://example.org" + path); + assertEquals(Collections.singletonList(new Id("x509", path.substring("/v1/".length()))), + cnxn.getAuthInfo()); + assertDenied(cnxn, "unrelated", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + for (String uri : Arrays.asList("spiffe://example.org/v1/wl/kafka", "urn:example:kafka")) { + MockServerCnxn exact = authenticate(uri); + assertEquals("kafka", exact.getX509ClientIdentity().getId()); + assertTrue(exact.getAuthInfo().contains(new Id("super", "kafka"))); + } + } + } + @Test public void testExactSuperUserDoesNotRequireCompatibility() throws Exception { System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "false"); @@ -395,7 +412,7 @@ public void testSuperUserCompatibilityRejectsOtherIdentityTypes() throws Excepti public void testSuperUserCompatibilityRequiresMatchingLegacyConfig() throws Exception { System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); for (String configuredId : Arrays.asList( - "other", "kafka-extra", "Kafka", "kafka)", "kafka;instance", "nested/kafka", + "kafka", "other", "kafka-extra", "Kafka", "kafka)", "kafka;instance", "nested/kafka", "application/other-mp/kafka", "servicePrincipal(other", "servicePrincipal(kafka-extra", "servicePrincipal(Kafka", "userPrincipal(kafka", "groupPrincipal(kafka", "servicePrincipalMetadata(kafka)", diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java index 25f2dc9e5ba..d0c82a1d892 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java @@ -226,18 +226,26 @@ public void testSpiffeSuperUserCompatibilityRequiresOptIn() throws Exception { } @Test - public void testBareSuperUserConfigurationSupportsSpiffeApplications() throws Exception { + public void testBareSuperUserConfigurationRequiresExactIdentity() throws Exception { System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, "kafka"); - for (String path : Arrays.asList( - "/v1/application/example-mp/kafka", "/v2/application/example-mp/kafka/blue")) { - String clientId = path.substring("/v1/".length()); - System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "false"); - Assert.assertEquals(Collections.singletonList(new Id("x509", clientId)), authenticateSpiffe(path).getAuthInfo()); - - System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); - MockServerCnxn superUser = authenticateSpiffe(path); - Assert.assertEquals(Collections.singletonList(new Id("super", "kafka")), superUser.getAuthInfo()); - Assert.assertEquals(clientId, superUser.getX509ClientIdentity().getId()); + for (String enabled : Arrays.asList("false", "true")) { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, enabled); + for (String path : Arrays.asList( + "/v1/application/example-mp/kafka", "/v2/application/example-mp/kafka/blue")) { + String clientId = path.substring("/v1/".length()); + MockServerCnxn cnxn = authenticateSpiffe(path); + Assert.assertEquals(Collections.singletonList(new Id("x509", clientId)), cnxn.getAuthInfo()); + Assert.assertEquals(clientId, cnxn.getX509ClientIdentity().getId()); + } + Assert.assertEquals(Collections.singletonList(new Id("super", "kafka")), + authenticateSpiffe("/v1/wl/kafka").getAuthInfo()); + X509AuthenticationConfig.reset(); + X509AuthTest.TestCertificate cert = new X509AuthTest.TestCertificate("CLIENT", "kafka"); + MockServerCnxn exact = new MockServerCnxn(); + exact.clientChain = new X509Certificate[]{cert}; + Assert.assertEquals(KeeperException.Code.OK, createProvider(cert).handleAuthentication( + new ServerAuthenticationProvider.ServerObjs(zks, exact), null)); + Assert.assertEquals(Collections.singletonList(new Id("super", "kafka")), exact.getAuthInfo()); } } @@ -316,7 +324,7 @@ public void testStructuredSuperUserIdsKeepExactLegacyMatches() throws Exception public void testCompatibleSuperUserRetainsExplicitAclPolicy() throws Exception { System.setProperty(X509AuthenticationConfig.SET_X509_CLIENT_ID_AS_ACL, "true"); List requested = Collections.singletonList(new ACL(ZooDefs.Perms.READ, ZooDefs.Ids.ANYONE_ID_UNSAFE)); - for (String configuredId : Arrays.asList("kafka", "servicePrincipal(kafka")) { + for (String configuredId : Arrays.asList("servicePrincipal(kafka", "servicePrincipal(kafka)")) { System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, configuredId); System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "false"); admin.create(CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/CrossDomain/" + configuredId, 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 ac08297a7f9..e81748c5f7a 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 @@ -416,7 +416,7 @@ public void testA6_LegacySanStillUsesOriginalMappingKey() throws Exception { } @Test - public void testA7_SpiffeApplicationUsesBareLegacyMapping() throws Exception { + public void testA7_SpiffeApplicationRequiresFormattedLegacyMapping() throws Exception { String mappingPath = CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/kafka"; for (String path : Arrays.asList( CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH, CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access", mappingPath)) { @@ -432,10 +432,16 @@ public void testA7_SpiffeApplicationUsesBareLegacyMapping() throws Exception { ServerAuthenticationProvider.ServerObjs serverObjs = new ServerAuthenticationProvider.ServerObjs(zookeeperServer, cnxn); Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); + Assert.assertEquals(Collections.singletonList(new Id("x509", clientId)), cnxn.getAuthInfo()); + Assert.assertEquals(clientId, cnxn.getX509ClientIdentity().getId()); + + String legacyMappingPath = CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/servicePrincipal(kafka"; + zookeeperClientConnection.create(legacyMappingPath, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); Assert.assertEquals(Collections.singletonList(new Id("x509", "broker-access")), cnxn.getAuthInfo()); Assert.assertEquals(clientId, cnxn.getX509ClientIdentity().getId()); - zookeeperClientConnection.delete(mappingPath, -1); + zookeeperClientConnection.delete(legacyMappingPath, -1); Assert.assertEquals(KeeperException.Code.OK, provider.handleAuthentication(serverObjs, null)); Assert.assertEquals(Collections.singletonList(new Id("x509", clientId)), cnxn.getAuthInfo()); } @@ -537,7 +543,7 @@ public void testD_SpiffeWorkloadMatchesLegacyApplicationNames() { CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { for (String clientId : Arrays.asList("application/example-mp/kafka", "application/example-mp/kafka/cluster-a")) { Assert.assertEquals(new HashSet<>(Arrays.asList( - "bare-domain", "truncated-domain", "closed-domain", "urn-domain")), + "truncated-domain", "closed-domain", "urn-domain")), helper.getDomains(type, clientId)); } } @@ -593,7 +599,7 @@ public void testD_TypedMultiSegmentLookupRetainsPrefixMatching() { CertificateType.SPIFFE_V1_WORKLOAD, CertificateType.SPIFFE_V2)) { Assert.assertEquals(Collections.singleton("path-domain"), helper.getDomains(type, "application/example-mp/kafka")); - Assert.assertEquals(new HashSet<>(Arrays.asList("bare-domain", "legacy-domain")), + Assert.assertEquals(Collections.singleton("legacy-domain"), helper.getDomains(type, "application/unrelated-mp/kafka")); Assert.assertEquals(Collections.emptySet(), helper.getDomains(type, "group/kafka")); } @@ -660,9 +666,10 @@ public void testD_ApplicationAliasesRequireSpiffeCertificateType() { } @Test - public void testD_BareNameGrammarDoesNotChangeExactLegacyMappings() { + public void testD_UnformattedNamesPreserveOnlyExactMappings() { ZkClientUriDomainMappingHelper helper = new ZkClientUriDomainMappingHelper(zookeeperServer); for (String target : Arrays.asList( + "kafka", "kafka-server", "kafka_1", "kafka.v2", "Kafka", "9kafka", "CN=admin", "urn:example:admin", "CN=admin,O=example", "kafka+worker", "kafka@realm", "_kafka", "-kafka", ".kafka")) { setMapping(helper, Collections.singletonMap(target, Collections.singleton("legacy-domain"))); @@ -671,6 +678,8 @@ public void testD_BareNameGrammarDoesNotChangeExactLegacyMappings() { Assert.assertEquals(Collections.emptySet(), helper.getDomains(type, "application/example-mp/" + target)); } Assert.assertEquals(Collections.singleton("legacy-domain"), helper.getDomains(CertificateType.LEGACY_SAN, target)); + Assert.assertEquals(Collections.singleton("legacy-domain"), helper.getDomains(CertificateType.SUBJECT_DN, target)); + Assert.assertEquals(Collections.singleton("legacy-domain"), helper.getDomains(CertificateType.SPIFFE_V1_WL, target)); Assert.assertEquals(Collections.singleton("legacy-domain"), helper.getDomains(target)); } } From ec2e336bbe3d05b8366d9cd8c5669ad7a57498c1 Mon Sep 17 00:00:00 2001 From: Aditi Bansal Date: Mon, 28 Sep 2026 09:23:21 +0530 Subject: [PATCH 16/16] Preserve X509 identity across quorum request forwarding Snapshot the authenticated certificate identity on each request and carry it through follower and observer forwarding as reserved, transport-only metadata. Restore the context before ACL matching while preserving ordinary AuthInfo, original IDs and identity-binding rules. Reject malformed metadata and client use of the reserved scheme. Cover request snapshots, quorum serialization and relay paths, provider parity and unchanged creator ACL behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/resources/markdown/zookeeperAdmin.md | 9 + .../server/FinalRequestProcessor.java | 42 +- .../server/PrepRequestProcessor.java | 12 +- .../org/apache/zookeeper/server/Request.java | 14 + .../zookeeper/server/ZooKeeperServer.java | 16 +- .../server/auth/AuthenticationProvider.java | 4 +- .../auth/LegacyServicePrincipalMatcher.java | 7 +- .../server/auth/ProviderRegistry.java | 4 + .../auth/ServerAuthenticationProvider.java | 11 + .../auth/WrappedAuthenticationProvider.java | 5 +- .../auth/X509AuthenticationProvider.java | 5 +- .../server/auth/X509AuthenticationUtil.java | 2 +- .../server/auth/X509QuorumAuthInfo.java | 94 +++++ .../groupacl/X509ZNodeGroupAclProvider.java | 2 +- .../zookeeper/server/quorum/Learner.java | 4 +- .../server/quorum/LearnerHandler.java | 32 +- .../server/quorum/LearnerSyncRequest.java | 9 +- .../server/auth/X509DirectAclTest.java | 103 +++++ .../server/auth/X509QuorumAuthInfoTest.java | 247 ++++++++++++ .../server/quorum/QuorumX509IdentityTest.java | 370 ++++++++++++++++++ 20 files changed, 929 insertions(+), 63 deletions(-) create mode 100644 zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509QuorumAuthInfo.java create mode 100644 zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509QuorumAuthInfoTest.java create mode 100644 zookeeper-server/src/test/java/org/apache/zookeeper/server/quorum/QuorumX509IdentityTest.java diff --git a/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md b/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md index 999101f58eb..ac966ee5eee 100644 --- a/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md +++ b/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md @@ -1403,6 +1403,15 @@ and [SASL authentication for ZooKeeper](https://cwiki.apache.org/confluence/disp extracted as **kafka** still matches that exact ID. A client explicitly mapped into domain **kafka** can still match the **x509:kafka** domain ACL. + Direct ACL matching uses authenticated identity context attached to the request, + including writes forwarded by followers or observers. Quorum requests carry this + context in a reserved transport-only **zookeeper-internal-x509** entry, removed + before authorization; it is not a client authentication scheme or a stored ACL. + Both the receiving server and the leader must support this context for forwarded + compatibility matches. Missing context does not enable an alias, so do not rely + on this compatibility during a mixed-version rollout. Exact IDs and existing + domain/superuser AuthInfo continue to use their ordinary authorization rules. + * *zookeeper.superUser* : (Java system property: **zookeeper.superUser**) Similar to **zookeeper.X509AuthenticationProvider.superUser** diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/FinalRequestProcessor.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/FinalRequestProcessor.java index 9f55be337c0..5b836ebd6d6 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/FinalRequestProcessor.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/FinalRequestProcessor.java @@ -268,11 +268,11 @@ public void processRequest(Request request) { Record rec; switch (readOp.getType()) { case OpCode.getChildren: - rec = handleGetChildrenRequest(readOp.toRequestRecord(), cnxn, request.authInfo); + rec = handleGetChildrenRequest(readOp.toRequestRecord(), request); subResult = new GetChildrenResult(((GetChildrenResponse) rec).getChildren()); break; case OpCode.getData: - rec = handleGetDataRequest(readOp.toRequestRecord(), cnxn, request.authInfo); + rec = handleGetDataRequest(readOp.toRequestRecord(), request); GetDataResponse gdr = (GetDataResponse) rec; subResult = new GetDataResult(gdr.getData(), gdr.getStat()); break; @@ -369,7 +369,7 @@ public void processRequest(Request request) { GetDataRequest getDataRequest = new GetDataRequest(); ByteBufferInputStream.byteBuffer2Record(request.request, getDataRequest); path = getDataRequest.getPath(); - rsp = handleGetDataRequest(getDataRequest, cnxn, request.authInfo); + rsp = handleGetDataRequest(getDataRequest, request); requestPathMetricsCollector.registerRequest(request.type, path); break; } @@ -426,9 +426,9 @@ public void processRequest(Request request) { throw new KeeperException.NoNodeException(); } zks.checkACL( - request.cnxn, + request, zks.getZKDatabase().aclForNode(n), - ZooDefs.Perms.READ | ZooDefs.Perms.ADMIN, request.authInfo, path, + ZooDefs.Perms.READ | ZooDefs.Perms.ADMIN, path, null); Stat stat = new Stat(); @@ -437,10 +437,9 @@ public void processRequest(Request request) { try { zks.checkACL( - request.cnxn, + request, zks.getZKDatabase().aclForNode(n), ZooDefs.Perms.ADMIN, - request.authInfo, path, null); rsp = new GetACLResponse(acl, stat); @@ -464,7 +463,7 @@ public void processRequest(Request request) { GetChildrenRequest getChildrenRequest = new GetChildrenRequest(); ByteBufferInputStream.byteBuffer2Record(request.request, getChildrenRequest); path = getChildrenRequest.getPath(); - rsp = handleGetChildrenRequest(getChildrenRequest, cnxn, request.authInfo); + rsp = handleGetChildrenRequest(getChildrenRequest, request); requestPathMetricsCollector.registerRequest(request.type, path); break; } @@ -478,10 +477,9 @@ public void processRequest(Request request) { throw new KeeperException.NoNodeException(); } zks.checkACL( - request.cnxn, + request, zks.getZKDatabase().aclForNode(n), ZooDefs.Perms.READ, - request.authInfo, path, null); int number = zks.getZKDatabase().getAllChildrenNumber(path); @@ -499,10 +497,10 @@ public void processRequest(Request request) { throw new KeeperException.NoNodeException(); } zks.checkACL( - request.cnxn, + request, zks.getZKDatabase().aclForNode(n), ZooDefs.Perms.READ, - request.authInfo, path, + path, null); List children = zks.getZKDatabase() .getChildren(path, stat, getChildren2Request.getWatch() ? cnxn : null); @@ -569,10 +567,10 @@ public void processRequest(Request request) { throw new KeeperException.NoNodeException(); } zks.checkACL( - request.cnxn, + request, zks.getZKDatabase().aclForNode(n), ZooDefs.Perms.READ, - request.authInfo, path, + path, null); final int maxReturned = getChildrenPaginatedRequest.getMaxReturned(); final PaginationNextPage nextPage = new PaginationNextPage(); @@ -674,29 +672,29 @@ public void processRequest(Request request) { } } - private Record handleGetChildrenRequest(Record request, ServerCnxn cnxn, List authInfo) throws KeeperException, IOException { - GetChildrenRequest getChildrenRequest = (GetChildrenRequest) request; + private Record handleGetChildrenRequest(Record record, Request request) throws KeeperException, IOException { + GetChildrenRequest getChildrenRequest = (GetChildrenRequest) record; String path = getChildrenRequest.getPath(); DataNode n = zks.getZKDatabase().getNode(path); if (n == null) { throw new KeeperException.NoNodeException(); } - zks.checkACL(cnxn, zks.getZKDatabase().aclForNode(n), ZooDefs.Perms.READ, authInfo, path, null); + zks.checkACL(request, zks.getZKDatabase().aclForNode(n), ZooDefs.Perms.READ, path, null); List children = zks.getZKDatabase() - .getChildren(path, null, getChildrenRequest.getWatch() ? cnxn : null); + .getChildren(path, null, getChildrenRequest.getWatch() ? request.cnxn : null); return new GetChildrenResponse(children); } - private Record handleGetDataRequest(Record request, ServerCnxn cnxn, List authInfo) throws KeeperException, IOException { - GetDataRequest getDataRequest = (GetDataRequest) request; + private Record handleGetDataRequest(Record record, Request request) throws KeeperException, IOException { + GetDataRequest getDataRequest = (GetDataRequest) record; String path = getDataRequest.getPath(); DataNode n = zks.getZKDatabase().getNode(path); if (n == null) { throw new KeeperException.NoNodeException(); } - zks.checkACL(cnxn, zks.getZKDatabase().aclForNode(n), ZooDefs.Perms.READ, authInfo, path, null); + zks.checkACL(request, zks.getZKDatabase().aclForNode(n), ZooDefs.Perms.READ, path, null); Stat stat = new Stat(); - byte[] b = zks.getZKDatabase().getData(path, stat, getDataRequest.getWatch() ? cnxn : null); + byte[] b = zks.getZKDatabase().getData(path, stat, getDataRequest.getWatch() ? request.cnxn : null); return new GetDataResponse(b, stat); } diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/PrepRequestProcessor.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/PrepRequestProcessor.java index c3a25539744..1fe4066fe51 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/PrepRequestProcessor.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/PrepRequestProcessor.java @@ -368,7 +368,7 @@ protected void pRequest2Txn(int type, long zxid, Request request, Record record, String path = deleteRequest.getPath(); String parentPath = getParentPathAndValidate(path); ChangeRecord parentRecord = getRecordForPath(parentPath); - zks.checkACL(request.cnxn, parentRecord.acl, ZooDefs.Perms.DELETE, request.authInfo, path, null); + zks.checkACL(request, parentRecord.acl, ZooDefs.Perms.DELETE, path, null); ChangeRecord nodeRecord = getRecordForPath(path); checkAndIncVersion(nodeRecord.stat.getVersion(), deleteRequest.getVersion(), path); if (nodeRecord.childCount > 0) { @@ -396,7 +396,7 @@ protected void pRequest2Txn(int type, long zxid, Request request, Record record, path = setDataRequest.getPath(); validatePath(path, request.sessionId); nodeRecord = getRecordForPath(path); - zks.checkACL(request.cnxn, nodeRecord.acl, ZooDefs.Perms.WRITE, request.authInfo, path, null); + zks.checkACL(request, nodeRecord.acl, ZooDefs.Perms.WRITE, path, null); int newVersion = checkAndIncVersion(nodeRecord.stat.getVersion(), setDataRequest.getVersion(), path); request.setTxn(new SetDataTxn(path, setDataRequest.getData(), newVersion)); nodeRecord = nodeRecord.duplicate(request.getHdr().getZxid()); @@ -536,7 +536,7 @@ protected void pRequest2Txn(int type, long zxid, Request request, Record record, } nodeRecord = getRecordForPath(ZooDefs.CONFIG_NODE); - zks.checkACL(request.cnxn, nodeRecord.acl, ZooDefs.Perms.WRITE, request.authInfo, null, null); + zks.checkACL(request, nodeRecord.acl, ZooDefs.Perms.WRITE, null, null); SetDataTxn setDataTxn = new SetDataTxn(ZooDefs.CONFIG_NODE, request.qv.toString().getBytes(), -1); request.setTxn(setDataTxn); nodeRecord = nodeRecord.duplicate(request.getHdr().getZxid()); @@ -562,7 +562,7 @@ protected void pRequest2Txn(int type, long zxid, Request request, Record record, validatePath(path, request.sessionId); List listACL = fixupACL(path, request.authInfo, setAclRequest.getAcl()); nodeRecord = getRecordForPath(path); - zks.checkACL(request.cnxn, nodeRecord.acl, ZooDefs.Perms.ADMIN, request.authInfo, path, listACL); + zks.checkACL(request, nodeRecord.acl, ZooDefs.Perms.ADMIN, path, listACL); newVersion = checkAndIncVersion(nodeRecord.stat.getAversion(), setAclRequest.getVersion(), path); request.setTxn(new SetACLTxn(path, listACL, newVersion)); nodeRecord = nodeRecord.duplicate(request.getHdr().getZxid()); @@ -633,7 +633,7 @@ protected void pRequest2Txn(int type, long zxid, Request request, Record record, path = checkVersionRequest.getPath(); validatePath(path, request.sessionId); nodeRecord = getRecordForPath(path); - zks.checkACL(request.cnxn, nodeRecord.acl, ZooDefs.Perms.READ, request.authInfo, path, null); + zks.checkACL(request, nodeRecord.acl, ZooDefs.Perms.READ, path, null); request.setTxn(new CheckVersionTxn( path, checkAndIncVersion(nodeRecord.stat.getVersion(), checkVersionRequest.getVersion(), path))); @@ -682,7 +682,7 @@ private void pRequest2TxnCreate(int type, Request request, Record record, boolea List listACL = fixupACL(path, request.authInfo, acl); ChangeRecord parentRecord = getRecordForPath(parentPath); - zks.checkACL(request.cnxn, parentRecord.acl, ZooDefs.Perms.CREATE, request.authInfo, path, listACL); + zks.checkACL(request, parentRecord.acl, ZooDefs.Perms.CREATE, path, listACL); int parentCVersion = parentRecord.stat.getCversion(); if (createMode.isSequential()) { path = path + String.format(Locale.ENGLISH, "%010d", parentCVersion); diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/Request.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/Request.java index e632b842e22..50d923f7a61 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/Request.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/Request.java @@ -27,6 +27,7 @@ import org.apache.zookeeper.data.Id; import org.apache.zookeeper.metrics.Summary; import org.apache.zookeeper.metrics.SummarySet; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; import org.apache.zookeeper.server.quorum.flexible.QuorumVerifier; import org.apache.zookeeper.server.util.AuthUtil; import org.apache.zookeeper.txn.TxnDigest; @@ -50,12 +51,18 @@ public class Request { private static volatile boolean staleLatencyCheck = Boolean.parseBoolean(System.getProperty("zookeeper.request_stale_latency_check", "false")); public Request(ServerCnxn cnxn, long sessionId, int xid, int type, ByteBuffer bb, List authInfo) { + this(cnxn, sessionId, xid, type, bb, authInfo, cnxn == null ? null : cnxn.getX509ClientIdentity()); + } + + public Request(ServerCnxn cnxn, long sessionId, int xid, int type, ByteBuffer bb, List authInfo, + ClientIdentity x509ClientIdentity) { this.cnxn = cnxn; this.sessionId = sessionId; this.cxid = xid; this.type = type; this.request = bb; this.authInfo = authInfo; + this.x509ClientIdentity = x509ClientIdentity; } public Request(long sessionId, int xid, int type, TxnHeader hdr, Record txn, long zxid) { @@ -68,6 +75,7 @@ public Request(long sessionId, int xid, int type, TxnHeader hdr, Record txn, lon this.request = null; this.cnxn = null; this.authInfo = null; + this.x509ClientIdentity = null; } public final long sessionId; @@ -88,6 +96,12 @@ public Request(long sessionId, int xid, int type, TxnHeader hdr, Record txn, lon public final List authInfo; + private final ClientIdentity x509ClientIdentity; + + public ClientIdentity getX509ClientIdentity() { + return x509ClientIdentity; + } + public final long createTime = Time.currentElapsedTime(); public long prepQueueStartTime = -1; diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/ZooKeeperServer.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/ZooKeeperServer.java index 2b2a5e5f569..b14b834611a 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/ZooKeeperServer.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/ZooKeeperServer.java @@ -1985,6 +1985,18 @@ public void dumpMonitorValues(BiConsumer response) { * @param setAcls : for set ACL operations, the list of ACLs being set. Otherwise null. */ public void checkACL(ServerCnxn cnxn, List acl, int perm, List ids, String path, List setAcls) throws KeeperException.NoAuthException { + checkACLWithContext(new ServerAuthenticationProvider.ServerObjs(this, cnxn), acl, perm, ids, path, setAcls); + } + + public void checkACL(Request request, List acl, int perm, String path, List setAcls) + throws KeeperException.NoAuthException { + checkACLWithContext( + new ServerAuthenticationProvider.ServerObjs(this, request.cnxn, request.getX509ClientIdentity()), + acl, perm, request.authInfo, path, setAcls); + } + + private void checkACLWithContext(ServerAuthenticationProvider.ServerObjs serverObjs, List acl, int perm, + List ids, String path, List setAcls) throws KeeperException.NoAuthException { if (skipACL) { return; } @@ -2012,7 +2024,7 @@ public void checkACL(ServerCnxn cnxn, List acl, int perm, List ids, Str for (Id authId : ids) { if (authId.getScheme().equals(id.getScheme()) && ap.matches( - new ServerAuthenticationProvider.ServerObjs(this, cnxn), + serverObjs, new ServerAuthenticationProvider.MatchValues(path, authId.getId(), id.getId(), perm, setAcls))) { return; } @@ -2142,7 +2154,7 @@ public boolean authWriteRequest(Request request) { try { pathToCheck = effectiveACLPath(request); if (pathToCheck != null) { - checkACL(request.cnxn, zkDb.getACL(pathToCheck, null), effectiveACLPerms(request), request.authInfo, pathToCheck, null); + checkACL(request, zkDb.getACL(pathToCheck, null), effectiveACLPerms(request), pathToCheck, null); } } catch (KeeperException.NoAuthException e) { LOG.debug("Request failed ACL check", e); diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/AuthenticationProvider.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/AuthenticationProvider.java index c1ab5e4099a..0031e56a563 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/AuthenticationProvider.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/AuthenticationProvider.java @@ -63,10 +63,10 @@ public interface AuthenticationProvider { boolean matches(String id, String aclExpr); /** - * Connection-aware matching for providers that need authenticated connection context. + * Context-aware matching for providers that need authenticated request identity. * Existing providers retain their string-only matching behavior. */ - default boolean matches(ServerCnxn cnxn, String id, String aclExpr) { + default boolean matches(ServerAuthenticationProvider.ServerObjs serverObjs, String id, String aclExpr) { return matches(id, aclExpr); } diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java index 802aad038e4..65914f5dc97 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java @@ -22,7 +22,6 @@ import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; -import org.apache.zookeeper.server.ServerCnxn; import org.apache.zookeeper.server.auth.X509AuthenticationUtil.CertificateType; import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; @@ -70,12 +69,8 @@ public static boolean matches(CertificateType certificateType, String clientId, return false; } - public static boolean matchesAuthenticatedClient(ServerCnxn cnxn, String authenticatedId, String aclId) { - if (cnxn == null) { - return false; - } + public static boolean matchesAuthenticatedClient(ClientIdentity identity, String authenticatedId, String aclId) { // Bind the candidate AuthInfo ID to the authenticated certificate identity, not a mapped domain. - ClientIdentity identity = cnxn.getX509ClientIdentity(); return identity != null && identity.getId().equals(authenticatedId) && matches(identity.getCertificateType(), authenticatedId, aclId); } diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/ProviderRegistry.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/ProviderRegistry.java index 856cf78687d..e880667a688 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/ProviderRegistry.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/ProviderRegistry.java @@ -71,6 +71,10 @@ public static ServerAuthenticationProvider getServerProvider(String scheme) { } public static AuthenticationProvider getProvider(String scheme) { + if (X509QuorumAuthInfo.AUTH_SCHEME.equals(scheme)) { + // Quorum metadata must never be accepted through client auth or explicit ACLs. + return null; + } if (!initialized) { initialize(); } diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/ServerAuthenticationProvider.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/ServerAuthenticationProvider.java index 0842296a9d1..ec29fabdabe 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/ServerAuthenticationProvider.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/ServerAuthenticationProvider.java @@ -23,6 +23,7 @@ import org.apache.zookeeper.data.ACL; import org.apache.zookeeper.server.ServerCnxn; import org.apache.zookeeper.server.ZooKeeperServer; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; /** * A variation on {@link AuthenticationProvider} that provides additional @@ -34,6 +35,7 @@ public static class ServerObjs { private final ZooKeeperServer zks; private final ServerCnxn cnxn; + private final ClientIdentity x509ClientIdentity; /** * @param zks @@ -42,8 +44,13 @@ public static class ServerObjs { * the cnxn that received the authentication information. */ public ServerObjs(ZooKeeperServer zks, ServerCnxn cnxn) { + this(zks, cnxn, cnxn == null ? null : cnxn.getX509ClientIdentity()); + } + + public ServerObjs(ZooKeeperServer zks, ServerCnxn cnxn, ClientIdentity x509ClientIdentity) { this.zks = zks; this.cnxn = cnxn; + this.x509ClientIdentity = x509ClientIdentity; } public ZooKeeperServer getZks() { @@ -54,6 +61,10 @@ public ServerCnxn getCnxn() { return cnxn; } + public ClientIdentity getX509ClientIdentity() { + return x509ClientIdentity; + } + } public static class MatchValues { diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/WrappedAuthenticationProvider.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/WrappedAuthenticationProvider.java index 203b22f8c42..1ccbc298680 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/WrappedAuthenticationProvider.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/WrappedAuthenticationProvider.java @@ -55,12 +55,11 @@ public KeeperException.Code handleAuthentication(ServerObjs serverObjs, byte[] a /** * {@inheritDoc} * - * forwards connection context while preserving legacy providers' default behavior + * forwards request context while preserving legacy providers' default behavior */ @Override public boolean matches(ServerObjs serverObjs, MatchValues matchValues) { - ServerCnxn cnxn = serverObjs == null ? null : serverObjs.getCnxn(); - return implementation.matches(cnxn, matchValues.getId(), matchValues.getAclExpr()); + return implementation.matches(serverObjs, matchValues.getId(), matchValues.getAclExpr()); } @Override diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java index 64d8710a92e..95b004201e6 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationProvider.java @@ -128,9 +128,10 @@ public boolean matches(String id, String aclExpr) { } @Override - public boolean matches(ServerCnxn cnxn, String id, String aclExpr) { + public boolean matches(ServerAuthenticationProvider.ServerObjs serverObjs, String id, String aclExpr) { return matches(id, aclExpr) - || LegacyServicePrincipalMatcher.matchesAuthenticatedClient(cnxn, id, aclExpr); + || LegacyServicePrincipalMatcher.matchesAuthenticatedClient( + serverObjs == null ? null : serverObjs.getX509ClientIdentity(), id, aclExpr); } @Override 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 776b2bbfa37..53685093f31 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 @@ -63,7 +63,7 @@ public static final class ClientIdentity { private final CertificateType certificateType; private final String id; - private ClientIdentity(CertificateType certificateType, String id) { + ClientIdentity(CertificateType certificateType, String id) { this.certificateType = Objects.requireNonNull(certificateType); this.id = Objects.requireNonNull(id); } diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509QuorumAuthInfo.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509QuorumAuthInfo.java new file mode 100644 index 00000000000..ca46dd6b7e2 --- /dev/null +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509QuorumAuthInfo.java @@ -0,0 +1,94 @@ +/* + * 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.server.auth; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.apache.zookeeper.data.Id; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.CertificateType; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; + +/** + * Transports authenticated identity context between quorum peers, separately from ordinary + * AuthInfo. The reserved entry is never a client authentication scheme or a stored ACL. + */ +public final class X509QuorumAuthInfo { + public static final String AUTH_SCHEME = "zookeeper-internal-x509"; + private static final String VERSION = "1"; + + private final List authInfo; + private final ClientIdentity clientIdentity; + + private X509QuorumAuthInfo(List authInfo, ClientIdentity clientIdentity) { + this.authInfo = authInfo; + this.clientIdentity = clientIdentity; + } + + public List getAuthInfo() { + return authInfo; + } + + public ClientIdentity getClientIdentity() { + return clientIdentity; + } + + public static List encode(List authInfo, ClientIdentity identity) throws IOException { + List encoded = authInfo == null ? new ArrayList<>() : new ArrayList<>(authInfo); + for (Id id : encoded) { + if (AUTH_SCHEME.equals(id.getScheme())) { + throw new IOException("Reserved X509 quorum metadata cannot be ordinary AuthInfo"); + } + } + if (identity == null) { + return authInfo; + } + encoded.add(new Id(AUTH_SCHEME, + VERSION + ":" + identity.getCertificateType().name() + ":" + identity.getId())); + return encoded; + } + + public static X509QuorumAuthInfo decode(List encoded) throws IOException { + if (encoded == null) { + return new X509QuorumAuthInfo(null, null); + } + List authInfo = new ArrayList<>(); + ClientIdentity identity = null; + for (Id id : encoded) { + if (!AUTH_SCHEME.equals(id.getScheme())) { + authInfo.add(id); + continue; + } + if (identity != null) { + throw new IOException("Duplicate X509 quorum identity metadata"); + } + String value = id.getId(); + String[] fields = value == null ? new String[0] : value.split(":", 3); + if (fields.length != 3 || !VERSION.equals(fields[0])) { + throw new IOException("Invalid X509 quorum identity metadata version or format"); + } + try { + identity = new ClientIdentity(CertificateType.valueOf(fields[1]), fields[2]); + } catch (IllegalArgumentException e) { + throw new IOException("Invalid X509 quorum certificate type", e); + } + } + return new X509QuorumAuthInfo(authInfo, identity); + } +} 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 2176634a033..90bf7d080ed 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 @@ -127,7 +127,7 @@ public boolean matches(ServerObjs serverObjs, MatchValues matchValues) { // in checkAcl() in ZookeeperServer.class return matchValues.getId().equals(matchValues.getAclExpr()) || LegacyServicePrincipalMatcher.matchesAuthenticatedClient( - serverObjs == null ? null : serverObjs.getCnxn(), matchValues.getId(), matchValues.getAclExpr()); + serverObjs == null ? null : serverObjs.getX509ClientIdentity(), matchValues.getId(), matchValues.getAclExpr()); } @Override diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/Learner.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/Learner.java index 44205e83e86..f9d8f6eb613 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/Learner.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/Learner.java @@ -52,6 +52,7 @@ import org.apache.zookeeper.server.ServerCnxn; import org.apache.zookeeper.server.TxnLogEntry; import org.apache.zookeeper.server.ZooTrace; +import org.apache.zookeeper.server.auth.X509QuorumAuthInfo; import org.apache.zookeeper.server.quorum.QuorumPeer.QuorumServer; import org.apache.zookeeper.server.quorum.flexible.QuorumVerifier; import org.apache.zookeeper.server.util.ConfigUtils; @@ -250,7 +251,8 @@ void request(Request request) throws IOException { oa.write(b); } oa.close(); - QuorumPacket qp = new QuorumPacket(Leader.REQUEST, -1, baos.toByteArray(), request.authInfo); + QuorumPacket qp = new QuorumPacket(Leader.REQUEST, -1, baos.toByteArray(), + X509QuorumAuthInfo.encode(request.authInfo, request.getX509ClientIdentity())); writePacket(qp, true); } diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/LearnerHandler.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/LearnerHandler.java index 4a7def87086..bf5fbccc71a 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/LearnerHandler.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/LearnerHandler.java @@ -46,6 +46,7 @@ import org.apache.zookeeper.server.ZKDatabase; import org.apache.zookeeper.server.ZooKeeperThread; import org.apache.zookeeper.server.ZooTrace; +import org.apache.zookeeper.server.auth.X509QuorumAuthInfo; import org.apache.zookeeper.server.quorum.Leader.Proposal; import org.apache.zookeeper.server.quorum.QuorumPeer.LearnerType; import org.apache.zookeeper.server.quorum.auth.QuorumAuthServer; @@ -661,11 +662,6 @@ public void run() { packetsReceived.incrementAndGet(); - ByteBuffer bb; - long sessionId; - int cxid; - int type; - switch (qp.getType()) { case Leader.ACK: if (this.learnerType == LearnerType.OBSERVER) { @@ -689,17 +685,7 @@ public void run() { learnerMaster.revalidateSession(qp, this); break; case Leader.REQUEST: - bb = ByteBuffer.wrap(qp.getData()); - sessionId = bb.getLong(); - cxid = bb.getInt(); - type = bb.getInt(); - bb = bb.slice(); - Request si; - if (type == OpCode.sync) { - si = new LearnerSyncRequest(this, sessionId, cxid, type, bb, qp.getAuthinfo()); - } else { - si = new Request(null, sessionId, cxid, type, bb, qp.getAuthinfo()); - } + Request si = readRequest(qp); si.setOwner(this); learnerMaster.submitLearnerRequest(si); requestsReceived.incrementAndGet(); @@ -740,6 +726,20 @@ public void run() { } } + Request readRequest(QuorumPacket packet) throws IOException { + ByteBuffer buffer = ByteBuffer.wrap(packet.getData()); + long sessionId = buffer.getLong(); + int cxid = buffer.getInt(); + int type = buffer.getInt(); + X509QuorumAuthInfo auth = X509QuorumAuthInfo.decode(packet.getAuthinfo()); + if (type == OpCode.sync) { + return new LearnerSyncRequest(this, sessionId, cxid, type, buffer.slice(), + auth.getAuthInfo(), auth.getClientIdentity()); + } + return new Request(null, sessionId, cxid, type, buffer.slice(), + auth.getAuthInfo(), auth.getClientIdentity()); + } + /** * Start thread that will forward any packet in the queue to the follower */ diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/LearnerSyncRequest.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/LearnerSyncRequest.java index d4c83aeab7b..300b5e9be0e 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/LearnerSyncRequest.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/LearnerSyncRequest.java @@ -22,13 +22,20 @@ import java.util.List; import org.apache.zookeeper.data.Id; import org.apache.zookeeper.server.Request; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; public class LearnerSyncRequest extends Request { LearnerHandler fh; public LearnerSyncRequest( LearnerHandler fh, long sessionId, int xid, int type, ByteBuffer bb, List authInfo) { - super(null, sessionId, xid, type, bb, authInfo); + this(fh, sessionId, xid, type, bb, authInfo, null); + } + + public LearnerSyncRequest( + LearnerHandler fh, long sessionId, int xid, int type, ByteBuffer bb, List authInfo, + ClientIdentity identity) { + super(null, sessionId, xid, type, bb, authInfo, identity); this.fh = fh; } diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java index fce8b47fc93..faef8fb7465 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java @@ -39,6 +39,7 @@ import org.apache.zookeeper.data.Id; import org.apache.zookeeper.server.MockServerCnxn; import org.apache.zookeeper.server.PrepRequestProcessor; +import org.apache.zookeeper.server.Request; import org.apache.zookeeper.server.ZooKeeperServer; import org.apache.zookeeper.server.auth.znode.groupacl.X509ZNodeGroupAclProvider; import org.apache.zookeeper.test.X509AuthTest.TestTrustManager; @@ -451,6 +452,108 @@ public void testLegacyProviderWrapperKeepsExistingStringMatcher() { "/protected", "192.0.2.1", "10.0.0.0/8", ZooDefs.Perms.READ, null))); } + @Test + public void testForwardedIdentitiesMatchFormattedAclsForBothProviders() throws Exception { + for (Class providerClass : Arrays.asList( + X509AuthenticationProvider.class, X509ZNodeGroupAclProvider.class)) { + selectProvider(providerClass); + for (String path : Arrays.asList( + "/v1/wl/kafka", "/v1/application/example-mp/kafka", + "/v2/application/example-mp/kafka", "/v2/application/example-mp/kafka/blue")) { + MockServerCnxn cnxn = authenticate("spiffe://example.org" + path); + Request request = forward(cnxn); + assertNull(request.cnxn); + assertEquals(cnxn.getAuthInfo(), request.authInfo); + for (String target : Arrays.asList("servicePrincipal(kafka", "servicePrincipal(kafka)", + "urn:li:servicePrincipal(kafka;region1;instance1)")) { + for (int permission : Arrays.asList(ZooDefs.Perms.READ, ZooDefs.Perms.WRITE, + ZooDefs.Perms.CREATE, ZooDefs.Perms.DELETE, ZooDefs.Perms.ADMIN)) { + server.checkACL(request, acl(target, permission), permission, "/protected", null); + } + } + assertDenied(request, "servicePrincipal(kafka", ZooDefs.Perms.READ, ZooDefs.Perms.WRITE); + assertDenied(request, "servicePrincipal(other", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + if (!"kafka".equals(request.getX509ClientIdentity().getId())) { + assertDenied(request, "kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + assertEquals(Collections.singletonList(new ACL(ZooDefs.Perms.ALL, + new Id("x509", request.getX509ClientIdentity().getId()))), + PrepRequestProcessor.fixupACL("/created", request.authInfo, ZooDefs.Ids.CREATOR_ALL_ACL)); + } + } + } + + @Test + public void testForwardedRequestsRequireIdentityContextAndMatchingAuthInfo() throws Exception { + for (Class providerClass : Arrays.asList( + X509AuthenticationProvider.class, X509ZNodeGroupAclProvider.class)) { + selectProvider(providerClass); + MockServerCnxn cnxn = authenticate("spiffe://example.org/v2/application/example-mp/kafka"); + Request missing = new Request(null, 1, 1, ZooDefs.OpCode.setData, null, cnxn.getAuthInfo()); + assertDenied(missing, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.WRITE); + server.checkACL(missing, acl("application/example-mp/kafka", ZooDefs.Perms.WRITE), + ZooDefs.Perms.WRITE, "/protected", null); + + MockServerCnxn other = authenticate("spiffe://example.org/v2/application/example-mp/reporting"); + for (Id id : other.getAuthInfo()) { + other.removeAuthInfo(id); + } + other.addAuthInfo(new Id("x509", "application/example-mp/kafka")); + Request mapped = forward(other); + server.checkACL(mapped, acl("application/example-mp/kafka", ZooDefs.Perms.READ), + ZooDefs.Perms.READ, "/protected", null); + assertDenied(mapped, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + assertDenied(mapped, "servicePrincipal(reporting", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + } + } + + @Test + public void testForwardedLegacyIdentitiesDoNotAcquireSpiffeType() throws Exception { + configureSan("^urn:example:(.*)$"); + for (Class providerClass : Arrays.asList( + X509AuthenticationProvider.class, X509ZNodeGroupAclProvider.class)) { + selectProvider(providerClass); + for (String clientId : Arrays.asList("kafka", "application/example-mp/kafka")) { + Request request = forward(authenticate("urn:example:" + clientId)); + assertEquals(X509AuthenticationUtil.CertificateType.LEGACY_SAN, + request.getX509ClientIdentity().getCertificateType()); + server.checkACL(request, acl(clientId, ZooDefs.Perms.WRITE), ZooDefs.Perms.WRITE, + "/protected", null); + assertDenied(request, "servicePrincipal(kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.WRITE); + } + } + } + + @Test + public void testRequestAclUsesItsAuthenticatedIdentitySnapshot() throws Exception { + MockServerCnxn cnxn = authenticate("spiffe://example.org/v2/application/example-mp/kafka"); + Request request = new Request(cnxn, 1, 1, ZooDefs.OpCode.setData, null, cnxn.getAuthInfo()); + cnxn.setX509ClientIdentity( + authenticate("spiffe://example.org/v2/application/example-mp/reporting").getX509ClientIdentity()); + for (Class providerClass : Arrays.asList( + X509AuthenticationProvider.class, X509ZNodeGroupAclProvider.class)) { + selectProvider(providerClass); + server.checkACL(request, acl("servicePrincipal(kafka", ZooDefs.Perms.WRITE), + ZooDefs.Perms.WRITE, "/protected", null); + assertDenied(request, "servicePrincipal(reporting", ZooDefs.Perms.ALL, ZooDefs.Perms.WRITE); + } + } + + private static Request forward(MockServerCnxn cnxn) throws Exception { + X509QuorumAuthInfo auth = X509QuorumAuthInfo.decode( + X509QuorumAuthInfo.encode(cnxn.getAuthInfo(), cnxn.getX509ClientIdentity())); + return new Request(null, 1, 1, ZooDefs.OpCode.setData, null, auth.getAuthInfo(), auth.getClientIdentity()); + } + + private void assertDenied(Request request, String id, int allowedPerms, int requestedPerm) { + try { + server.checkACL(request, acl(id, allowedPerms), requestedPerm, "/protected", null); + fail("Unexpected forwarded access to ACL " + id); + } catch (KeeperException.NoAuthException expected) { + // Expected denial. + } + } + private MockServerCnxn authenticate(String... uriSans) throws Exception { X509Certificate cert = SpiffeAuthTestUtil.buildClientCertWithUriSans(uriSans); X509AuthenticationProvider provider = new X509AuthenticationProvider( diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509QuorumAuthInfoTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509QuorumAuthInfoTest.java new file mode 100644 index 00000000000..2cb6a98343b --- /dev/null +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509QuorumAuthInfoTest.java @@ -0,0 +1,247 @@ +/* + * 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.server.auth; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.apache.zookeeper.KeeperException; +import org.apache.zookeeper.ZKTestCase; +import org.apache.zookeeper.ZooDefs; +import org.apache.zookeeper.data.ACL; +import org.apache.zookeeper.data.Id; +import org.apache.zookeeper.server.PrepRequestProcessor; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.CertificateType; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; +import org.junit.Test; + +public final class X509QuorumAuthInfoTest extends ZKTestCase { + + private static final String ORIGINAL_ID = "application/example-mp/Kafka:blue;instance"; + private static final String PROVIDER_PROPERTY = + ProviderRegistry.AUTHPROVIDER_PROPERTY_PREFIX + "quorum-metadata-regression"; + + @Test + public void testRoundTripEveryCertificateTypePreservesOriginalIds() throws Exception { + for (CertificateType type : CertificateType.values()) { + for (String originalId : Arrays.asList( + "Kafka", + ORIGINAL_ID, + "servicePrincipal(kafka", + "urn:li:servicePrincipal(kafka;region1;instance1)", + "CN=Kafka:blue;instance,O=Example")) { + assertRoundTrip(type, originalId, ordinaryAuthInfo()); + } + } + } + + @Test + public void testRoundTripPreservesEmptyLegacyId() throws Exception { + assertRoundTrip(CertificateType.LEGACY_SAN, "", ordinaryAuthInfo()); + } + + @Test + public void testRoundTripIdentityWithoutOrdinaryAuthInfo() throws Exception { + assertRoundTrip(CertificateType.SPIFFE_V2, ORIGINAL_ID, null); + assertRoundTrip(CertificateType.SPIFFE_V2, ORIGINAL_ID, Collections.emptyList()); + } + + @Test + public void testAbsentMetadataPreservesNullEmptyAndOrdinaryAuthInfo() throws Exception { + assertNull(X509QuorumAuthInfo.encode(null, null)); + X509QuorumAuthInfo nullAuth = X509QuorumAuthInfo.decode(null); + assertNull(nullAuth.getAuthInfo()); + assertNull(nullAuth.getClientIdentity()); + + for (List authInfo : Arrays.asList(Collections.emptyList(), ordinaryAuthInfo())) { + List expected = copyIds(authInfo); + List encoded = X509QuorumAuthInfo.encode(Collections.unmodifiableList(authInfo), null); + X509QuorumAuthInfo decoded = X509QuorumAuthInfo.decode(encoded); + + assertEquals(expected, encoded); + assertEquals(expected, decoded.getAuthInfo()); + assertEquals(expected, authInfo); + assertNull(decoded.getClientIdentity()); + } + } + + @Test + public void testDecodeStripsMetadataAtAnyPositionWithoutChangingWireList() throws Exception { + List ordinary = ordinaryAuthInfo(); + for (int position = 0; position <= ordinary.size(); position++) { + List wire = copyIds(ordinary); + wire.add(position, marker("1:SPIFFE_V2:" + ORIGINAL_ID)); + List expectedWire = copyIds(wire); + + X509QuorumAuthInfo decoded = X509QuorumAuthInfo.decode(Collections.unmodifiableList(wire)); + + assertEquals(ordinary, decoded.getAuthInfo()); + assertEquals(CertificateType.SPIFFE_V2, decoded.getClientIdentity().getCertificateType()); + assertEquals(ORIGINAL_ID, decoded.getClientIdentity().getId()); + assertEquals(expectedWire, wire); + } + } + + @Test + public void testDecodeRejectsMalformedOrUnsupportedMetadata() { + for (String value : Arrays.asList( + null, "", "1", "1:SPIFFE_V2", ":SPIFFE_V2:kafka", + "0:SPIFFE_V2:kafka", "2:SPIFFE_V2:kafka", "01:SPIFFE_V2:kafka", + "1::kafka", "1:UNKNOWN:kafka", "1:spiffe_v2:kafka")) { + assertDecodeRejected(Arrays.asList(new Id("ip", "127.0.0.1"), marker(value))); + } + } + + @Test + public void testDecodeRejectsDuplicateAndConflictingMarkers() { + Id first = marker("1:SPIFFE_V2:" + ORIGINAL_ID); + for (Id second : Arrays.asList( + marker(first.getId()), + marker("1:SPIFFE_V1_WORKLOAD:" + ORIGINAL_ID), + marker("1:SPIFFE_V2:application/other-mp/other-app"))) { + assertDecodeRejected(Arrays.asList(first, new Id("ip", "127.0.0.1"), second)); + } + assertDecodeRejected(Arrays.asList(marker("1:LEGACY_SAN:"), marker("1:LEGACY_SAN:"))); + } + + @Test + public void testEncodeRejectsContaminatedAuthInfoWithOrWithoutIdentity() { + ClientIdentity identity = new ClientIdentity(CertificateType.SPIFFE_V2, ORIGINAL_ID); + for (ClientIdentity context : Arrays.asList(null, identity)) { + for (String value : Arrays.asList(null, "invalid", "1:SPIFFE_V2:" + ORIGINAL_ID)) { + List authInfo = ordinaryAuthInfo(); + authInfo.add(1, marker(value)); + List expected = copyIds(authInfo); + + try { + X509QuorumAuthInfo.encode(Collections.unmodifiableList(authInfo), context); + fail("Reserved metadata must not be accepted as ordinary AuthInfo"); + } catch (IOException expectedException) { + assertIdsEqual(expected, authInfo); + } + } + } + } + + @Test + public void testReservedSchemeCannotBeUsedForClientAuthOrExplicitAclEvenWhenRegistered() throws Exception { + String originalProvider = System.getProperty(PROVIDER_PROPERTY); + try { + System.setProperty(PROVIDER_PROPERTY, ReservedSchemeProvider.class.getName()); + ProviderRegistry.reset(); + ProviderRegistry.initialize(); + assertTrue(ProviderRegistry.listProviders().contains(X509QuorumAuthInfo.AUTH_SCHEME + " ")); + + assertNull(ProviderRegistry.getProvider(X509QuorumAuthInfo.AUTH_SCHEME)); + assertNull(ProviderRegistry.getServerProvider(X509QuorumAuthInfo.AUTH_SCHEME)); + List acls = Collections.singletonList( + new ACL(ZooDefs.Perms.ALL, marker("1:SPIFFE_V2:" + ORIGINAL_ID))); + try { + PrepRequestProcessor.fixupACL("/protected", Collections.emptyList(), acls); + fail("Quorum metadata must not be accepted as an explicit ACL"); + } catch (KeeperException.InvalidACLException expected) { + assertEquals(KeeperException.Code.INVALIDACL, expected.code()); + } + } finally { + if (originalProvider == null) { + System.clearProperty(PROVIDER_PROPERTY); + } else { + System.setProperty(PROVIDER_PROPERTY, originalProvider); + } + ProviderRegistry.reset(); + } + } + + public static final class ReservedSchemeProvider extends IPAuthenticationProvider { + @Override + public String getScheme() { + return X509QuorumAuthInfo.AUTH_SCHEME; + } + + @Override + public boolean isValid(String id) { + return true; + } + } + + private static void assertRoundTrip(CertificateType type, String originalId, List ordinary) + throws IOException { + List expectedOrdinary = ordinary == null ? Collections.emptyList() : copyIds(ordinary); + List expectedWire = copyIds(expectedOrdinary); + expectedWire.add(marker("1:" + type.name() + ":" + originalId)); + List input = ordinary == null ? null : Collections.unmodifiableList(ordinary); + + List encoded = X509QuorumAuthInfo.encode(input, new ClientIdentity(type, originalId)); + assertEquals(expectedWire, encoded); + X509QuorumAuthInfo decoded = X509QuorumAuthInfo.decode(Collections.unmodifiableList(encoded)); + + assertEquals(type, decoded.getClientIdentity().getCertificateType()); + assertEquals(originalId, decoded.getClientIdentity().getId()); + assertEquals(expectedOrdinary, decoded.getAuthInfo()); + assertEquals(expectedWire, encoded); + if (ordinary != null) { + assertEquals(expectedOrdinary, ordinary); + } + } + + private static void assertDecodeRejected(List wire) { + List expectedWire = copyIds(wire); + try { + X509QuorumAuthInfo.decode(Collections.unmodifiableList(wire)); + fail("Malformed or duplicate quorum metadata must be rejected"); + } catch (IOException expected) { + assertIdsEqual(expectedWire, wire); + } + } + + private static void assertIdsEqual(List expected, List actual) { + // Generated Id.equals dereferences the ID, including deliberately malformed null IDs. + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < expected.size(); i++) { + assertEquals(expected.get(i).getScheme(), actual.get(i).getScheme()); + assertEquals(expected.get(i).getId(), actual.get(i).getId()); + } + } + + private static List ordinaryAuthInfo() { + return new ArrayList<>(Arrays.asList( + new Id("x509", "urn:li:servicePrincipal(kafka;region1;instance1)"), + new Id("ip", "127.0.0.1"), + new Id("x509", "1:SPIFFE_V2:application/not-metadata"), + new Id("digest", "client:hashed-credentials"))); + } + + private static Id marker(String value) { + return new Id(X509QuorumAuthInfo.AUTH_SCHEME, value); + } + + private static List copyIds(List ids) { + List copy = new ArrayList<>(); + for (Id id : ids) { + copy.add(new Id(id.getScheme(), id.getId())); + } + return copy; + } +} diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/quorum/QuorumX509IdentityTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/quorum/QuorumX509IdentityTest.java new file mode 100644 index 00000000000..d8c640bf89f --- /dev/null +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/quorum/QuorumX509IdentityTest.java @@ -0,0 +1,370 @@ +/* + * 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.server.quorum; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.apache.jute.BinaryInputArchive; +import org.apache.jute.BinaryOutputArchive; +import org.apache.jute.Record; +import org.apache.zookeeper.ZKTestCase; +import org.apache.zookeeper.ZooDefs.OpCode; +import org.apache.zookeeper.common.SpiffeAuthTestUtil; +import org.apache.zookeeper.data.Id; +import org.apache.zookeeper.proto.SetDataRequest; +import org.apache.zookeeper.proto.SyncRequest; +import org.apache.zookeeper.server.MockServerCnxn; +import org.apache.zookeeper.server.Request; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.CertificateType; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; +import org.apache.zookeeper.server.auth.X509QuorumAuthInfo; +import org.junit.BeforeClass; +import org.junit.Test; + +public final class QuorumX509IdentityTest extends ZKTestCase { + + private static final long SESSION_ID = 0x123456789abcdefL; + private static final int XID = 0x13579bdf; + private static final String SPIFFE_V2_URI = "spiffe://example.org/v2/application/example-mp/kafka/cluster-a"; + private static final String SPIFFE_V2_ID = "application/example-mp/kafka/cluster-a"; + + @BeforeClass + public static void registerBouncyCastle() { + SpiffeAuthTestUtil.registerBouncyCastle(); + } + + @Test + public void testForwardedRequestPreservesTypedIdentityAndRequestBytes() throws Exception { + MockServerCnxn cnxn = connectionWithIdentity(SPIFFE_V2_URI); + ClientIdentity identity = cnxn.getX509ClientIdentity(); + assertEquals(CertificateType.SPIFFE_V2, identity.getCertificateType()); + assertEquals(SPIFFE_V2_ID, identity.getId()); + List originalAuth = copyIds(cnxn.getAuthInfo()); + byte[] body = setDataBody(); + Request request = new Request(cnxn, SESSION_ID, XID, OpCode.setData, ByteBuffer.wrap(body), cnxn.getAuthInfo()); + LearnerHandler handler = handler(mock(Leader.class)); + + QuorumPacket packet = forward(request); + List wireAuth = copyIds(packet.getAuthinfo()); + Request forwarded = handler.readRequest(packet); + + byte[] expectedPacketData = ByteBuffer.allocate(Long.BYTES + 2 * Integer.BYTES + body.length) + .putLong(SESSION_ID).putInt(XID).putInt(OpCode.setData).put(body).array(); + assertArrayEquals(expectedPacketData, packet.getData()); + assertTransportAuthInfo(packet, originalAuth, identity); + assertForwardedRequest(request, forwarded, body, identity); + assertEquals(originalAuth, request.authInfo); + assertEquals(originalAuth, cnxn.getAuthInfo()); + assertEquals(wireAuth, packet.getAuthinfo()); + assertArrayEquals(body, bufferBytes(request.request)); + } + + @Test + public void testRelayPreservesIdentityWithExactlyOneTransportMarker() throws Exception { + MockServerCnxn cnxn = connectionWithIdentity("spiffe://example.org/v1/application/example-mp/kafka"); + ClientIdentity identity = cnxn.getX509ClientIdentity(); + assertEquals(CertificateType.SPIFFE_V1_WORKLOAD, identity.getCertificateType()); + assertEquals("application/example-mp/kafka", identity.getId()); + List originalAuth = copyIds(cnxn.getAuthInfo()); + byte[] body = setDataBody(); + Request original = new Request(cnxn, SESSION_ID, XID, OpCode.setData, ByteBuffer.wrap(body), cnxn.getAuthInfo()); + LearnerHandler observerHandler = handler(mock(ObserverMaster.class)); + LearnerHandler leaderHandler = handler(mock(Leader.class)); + + QuorumPacket firstPacket = forward(original); + Request relayRequest = observerHandler.readRequest(firstPacket); + QuorumPacket relayedPacket = forward(relayRequest); + Request leaderRequest = leaderHandler.readRequest(relayedPacket); + + assertTransportAuthInfo(firstPacket, originalAuth, identity); + assertTransportAuthInfo(relayedPacket, originalAuth, identity); + assertForwardedRequest(original, relayRequest, body, identity); + assertForwardedRequest(original, leaderRequest, body, identity); + assertArrayEquals(firstPacket.getData(), relayedPacket.getData()); + assertEquals(originalAuth, original.authInfo); + assertEquals(originalAuth, cnxn.getAuthInfo()); + } + + @Test + public void testSyncRetainsIdentityAndReceivingHandlerAcrossRelay() throws Exception { + MockServerCnxn cnxn = connectionWithIdentity("spiffe://example.org/v1/wl/kafka"); + ClientIdentity identity = cnxn.getX509ClientIdentity(); + assertEquals(CertificateType.SPIFFE_V1_WL, identity.getCertificateType()); + assertEquals("kafka", identity.getId()); + List originalAuth = copyIds(cnxn.getAuthInfo()); + byte[] body = serialize(new SyncRequest("/sync-target")); + Request original = new Request(cnxn, SESSION_ID, XID, OpCode.sync, ByteBuffer.wrap(body), cnxn.getAuthInfo()); + LearnerHandler observerHandler = handler(mock(ObserverMaster.class)); + LearnerHandler leaderHandler = handler(mock(Leader.class)); + + QuorumPacket firstPacket = forward(original); + Request observerSync = observerHandler.readRequest(firstPacket); + QuorumPacket relayedPacket = forward(observerSync); + Request leaderSync = leaderHandler.readRequest(relayedPacket); + + assertTrue(observerSync instanceof LearnerSyncRequest); + assertSame(observerHandler, ((LearnerSyncRequest) observerSync).fh); + assertTrue(leaderSync instanceof LearnerSyncRequest); + assertSame(leaderHandler, ((LearnerSyncRequest) leaderSync).fh); + assertForwardedRequest(original, observerSync, body, identity); + assertForwardedRequest(original, leaderSync, body, identity); + assertTransportAuthInfo(firstPacket, originalAuth, identity); + assertTransportAuthInfo(relayedPacket, originalAuth, identity); + assertEquals(originalAuth, original.authInfo); + assertEquals(originalAuth, cnxn.getAuthInfo()); + } + + @Test + public void testRequestSnapshotsIdentityBeforeConnectionCacheIsCleared() throws Exception { + MockServerCnxn cnxn = connectionWithIdentity(SPIFFE_V2_URI); + ClientIdentity originalIdentity = cnxn.getX509ClientIdentity(); + List originalAuth = copyIds(cnxn.getAuthInfo()); + byte[] body = setDataBody(); + Request request = new Request(cnxn, SESSION_ID, XID, OpCode.setData, ByteBuffer.wrap(body), cnxn.getAuthInfo()); + + cnxn.setX509ClientIdentity(null); + QuorumPacket packet = forward(request); + Request forwarded = handler(mock(Leader.class)).readRequest(packet); + + assertNull(cnxn.getX509ClientIdentity()); + assertSame(originalIdentity, request.getX509ClientIdentity()); + assertTransportAuthInfo(packet, originalAuth, originalIdentity); + assertForwardedRequest(request, forwarded, body, originalIdentity); + assertEquals(originalAuth, request.authInfo); + assertEquals(originalAuth, cnxn.getAuthInfo()); + } + + @Test + public void testRequestSnapshotsIdentityBeforeConnectionCacheIsReplaced() throws Exception { + MockServerCnxn cnxn = connectionWithIdentity(SPIFFE_V2_URI); + ClientIdentity originalIdentity = cnxn.getX509ClientIdentity(); + List originalAuth = copyIds(cnxn.getAuthInfo()); + byte[] body = setDataBody(); + Request request = new Request(cnxn, SESSION_ID, XID, OpCode.setData, ByteBuffer.wrap(body), cnxn.getAuthInfo()); + ClientIdentity replacement = connectionWithIdentity("spiffe://example.org/v1/wl/other-app") + .getX509ClientIdentity(); + + cnxn.setX509ClientIdentity(replacement); + QuorumPacket packet = forward(request); + Request forwarded = handler(mock(Leader.class)).readRequest(packet); + + assertSame(replacement, cnxn.getX509ClientIdentity()); + assertSame(originalIdentity, request.getX509ClientIdentity()); + assertTransportAuthInfo(packet, originalAuth, originalIdentity); + assertForwardedRequest(request, forwarded, body, originalIdentity); + assertEquals(originalAuth, request.authInfo); + assertEquals(originalAuth, cnxn.getAuthInfo()); + } + + @Test + public void testForwardingLegacyAuthWithoutMetadataDoesNotInferTypedIdentity() throws Exception { + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.addAuthInfo(new Id("x509", "urn:li:servicePrincipal(kafka;region1;instance1)")); + cnxn.addAuthInfo(new Id("x509", "1:SPIFFE_V2:application/not-metadata")); + cnxn.addAuthInfo(new Id("ip", "127.0.0.1")); + List originalAuth = copyIds(cnxn.getAuthInfo()); + byte[] body = setDataBody(); + Request original = new Request(cnxn, SESSION_ID, XID, OpCode.setData, ByteBuffer.wrap(body), cnxn.getAuthInfo()); + + QuorumPacket packet = forward(original); + Request forwarded = handler(mock(Leader.class)).readRequest(packet); + QuorumPacket relayedPacket = forward(forwarded); + Request relayed = handler(mock(Leader.class)).readRequest(relayedPacket); + + assertEquals(originalAuth, packet.getAuthinfo()); + assertEquals(originalAuth, relayedPacket.getAuthinfo()); + assertForwardedRequest(original, forwarded, body, null); + assertForwardedRequest(original, relayed, body, null); + assertNull(original.getX509ClientIdentity()); + assertEquals(originalAuth, cnxn.getAuthInfo()); + } + + @Test + public void testForwardingNullOrEmptyAuthInfoAndBodyNeedsNoIdentity() throws Exception { + for (List authInfo : Arrays.>asList(null, Collections.emptyList())) { + Request request = new Request(null, SESSION_ID, XID, OpCode.closeSession, null, authInfo); + + QuorumPacket packet = forward(request); + Request forwarded = handler(mock(Leader.class)).readRequest(packet); + + assertEquals(authInfo, packet.getAuthinfo()); + assertForwardedRequest(request, forwarded, new byte[0], null); + } + } + + @Test + public void testReadRequestRejectsCorruptTransportMetadata() throws Exception { + Request request = new Request(null, SESSION_ID, XID, OpCode.setData, + ByteBuffer.wrap(setDataBody()), Collections.singletonList(new Id("ip", "127.0.0.1"))); + LearnerHandler handler = handler(mock(Leader.class)); + for (String invalid : Arrays.asList(null, "1:SPIFFE_V2", "2:SPIFFE_V2:kafka", "1:UNKNOWN:kafka")) { + QuorumPacket packet = forward(request); + packet.getAuthinfo().add(new Id(X509QuorumAuthInfo.AUTH_SCHEME, invalid)); + + assertRequestRejected(handler, packet); + } + } + + @Test + public void testReadRequestRejectsDuplicateAndConflictingTransportMetadata() throws Exception { + MockServerCnxn cnxn = connectionWithIdentity(SPIFFE_V2_URI); + List originalAuth = copyIds(cnxn.getAuthInfo()); + Request request = new Request(cnxn, SESSION_ID, XID, OpCode.setData, + ByteBuffer.wrap(setDataBody()), cnxn.getAuthInfo()); + LearnerHandler handler = handler(mock(Leader.class)); + for (String duplicate : Arrays.asList( + "1:SPIFFE_V2:" + SPIFFE_V2_ID, "1:LEGACY_SAN:urn:li:servicePrincipal(other;region;instance)")) { + QuorumPacket packet = forward(request); + packet.getAuthinfo().add(new Id(X509QuorumAuthInfo.AUTH_SCHEME, duplicate)); + + assertRequestRejected(handler, packet); + assertEquals(originalAuth, request.authInfo); + assertEquals(originalAuth, cnxn.getAuthInfo()); + } + } + + private static final class CapturingLearner extends Learner { + private final List packets = new ArrayList<>(); + private boolean flushed; + + @Override + void writePacket(QuorumPacket packet, boolean flush) { + packets.add(packet); + flushed = flush; + } + } + + private static QuorumPacket forward(Request request) throws IOException { + CapturingLearner learner = new CapturingLearner(); + learner.request(request); + assertEquals(1, learner.packets.size()); + assertTrue(learner.flushed); + QuorumPacket packet = learner.packets.get(0); + assertEquals(Leader.REQUEST, packet.getType()); + assertEquals(-1L, packet.getZxid()); + + // Exercise the actual Jute authinfo vector rather than passing an in-memory list directly. + QuorumPacket received = new QuorumPacket(); + BinaryInputArchive.getArchive(new ByteArrayInputStream(serialize(packet))).readRecord(received, "packet"); + assertEquals(packet.getType(), received.getType()); + assertEquals(packet.getZxid(), received.getZxid()); + assertArrayEquals(packet.getData(), received.getData()); + assertEquals(packet.getAuthinfo(), received.getAuthinfo()); + return received; + } + + private static LearnerHandler handler(LearnerMaster master) throws IOException { + Socket socket = mock(Socket.class); + when(socket.getRemoteSocketAddress()).thenReturn(new InetSocketAddress("127.0.0.1", 12345)); + when(socket.getInputStream()).thenReturn(new ByteArrayInputStream(new byte[0])); + return new LearnerHandler(socket, new BufferedInputStream(socket.getInputStream()), master); + } + + private static MockServerCnxn connectionWithIdentity(String uri) throws Exception { + X509Certificate certificate = SpiffeAuthTestUtil.buildClientCertWithUriSans(uri); + ClientIdentity identity = X509AuthenticationUtil.getClientId(certificate); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.setX509ClientIdentity(identity); + cnxn.addAuthInfo(new Id("ip", "127.0.0.1")); + cnxn.addAuthInfo(new Id("x509", identity.getId())); + cnxn.addAuthInfo(new Id("digest", "client:hashed-credentials")); + return cnxn; + } + + private static byte[] setDataBody() throws IOException { + return serialize(new SetDataRequest("/protected:node", new byte[]{0, 1, -1, 127, -128}, 7)); + } + + private static byte[] serialize(Record record) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + BinaryOutputArchive.getArchive(bytes).writeRecord(record, "packet"); + return bytes.toByteArray(); + } + + private static void assertForwardedRequest(Request original, Request forwarded, byte[] body, ClientIdentity identity) { + assertNull(forwarded.cnxn); + assertEquals(original.sessionId, forwarded.sessionId); + assertEquals(original.cxid, forwarded.cxid); + assertEquals(original.type, forwarded.type); + assertArrayEquals(body, bufferBytes(forwarded.request)); + assertEquals(original.authInfo, forwarded.authInfo); + if (identity == null) { + assertNull(forwarded.getX509ClientIdentity()); + } else { + assertEquals(identity.getCertificateType(), forwarded.getX509ClientIdentity().getCertificateType()); + assertEquals(identity.getId(), forwarded.getX509ClientIdentity().getId()); + } + } + + private static void assertTransportAuthInfo(QuorumPacket packet, List ordinary, ClientIdentity identity) { + List expected = copyIds(ordinary); + expected.add(new Id(X509QuorumAuthInfo.AUTH_SCHEME, + "1:" + identity.getCertificateType().name() + ":" + identity.getId())); + assertEquals(expected, packet.getAuthinfo()); + } + + private static void assertRequestRejected(LearnerHandler handler, QuorumPacket packet) { + List expectedAuth = copyIds(packet.getAuthinfo()); + try { + handler.readRequest(packet); + fail("Malformed or duplicate transport metadata must reject the forwarded request"); + } catch (IOException expected) { + // Generated Id.equals cannot compare the deliberately malformed null ID. + assertEquals(expectedAuth.size(), packet.getAuthinfo().size()); + for (int i = 0; i < expectedAuth.size(); i++) { + assertEquals(expectedAuth.get(i).getScheme(), packet.getAuthinfo().get(i).getScheme()); + assertEquals(expectedAuth.get(i).getId(), packet.getAuthinfo().get(i).getId()); + } + } + } + + private static byte[] bufferBytes(ByteBuffer buffer) { + ByteBuffer copy = buffer.duplicate(); + byte[] bytes = new byte[copy.remaining()]; + copy.get(bytes); + return bytes; + } + + private static List copyIds(List ids) { + List copy = new ArrayList<>(); + for (Id id : ids) { + copy.add(new Id(id.getScheme(), id.getId())); + } + return copy; + } +}