Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ 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";

private static final String DEFAULT_REGEX = ".*";
private String clientCertIdType;
private int clientCertIdSanMatchType = -1;
Expand Down Expand Up @@ -482,4 +483,5 @@ public static void reset() {
instance = null;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -48,6 +50,39 @@ 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<N>/user" or "/v<N>/user/<rest>".
// User-identity SPIFFE certs (issued to humans, not workloads) must NOT be promoted to a
// service principal, otherwise a user credential would be granted service-level ACL access.
// See LISPIFFE-ID spec: https://github.com/linkedin-multiproduct/gopki/blob/master/LISPIFFE-ID.md
private static final Pattern SPIFFE_USER_IDENTITY_PATH_PATTERN =
Pattern.compile("^/v\\d+/user(/.*)?$");

// Matches LISPIFFE v2 paths and captures the ILM UID (the path after "/v2/").
// The canonical ILM v2 principal is the full path-after-v2 (e.g. "application/foo-mp/bar-app");
// ACL matching downstream is segment-prefix on this UID.
private static final Pattern SPIFFE_V2_PATH_PATTERN = Pattern.compile("^/v2/(.+)$");
Comment on lines +59 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in another comment - do add support for both SPIFFE v1 and v2.

SPIFFE URI format: spiffe://<trust-domain/<version>/<unique-identity>

Examples of SPIFFE v1:
spiffe://trust-domain/v1/wl/<app-name>

Therefore parsing of the workload unique-identity will get wl/<app-name>
Remove the prefix wl to get the .

Examples SPIFFE v2:
User: spiffe://trust-domain/v2/user/<userId>
Online application: spiffe://trust-domain/v2/application/<ILM-unique-id>

Flyte Workflow (Although I think, this is not the usecase for ZK).

More details here: https://github.com/linkedin-multiproduct/gopki/blob/master/LISPIFFE-ID.md#2-uri-path

@Sanju98 Sanju98 May 15, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @rgodha — addressed in 4a0533b.

The extractor now accepts both versions:

  • v2 (existing): spiffe://<td>/v2/<path> → principal is the full path-after-/v2/ (the ILM UID, e.g. application/foo-mp/bar-app).
  • v1 workload (new): spiffe://<td>/v1/wl/<app-name> → strip the wl/ type prefix; principal is just <app-name>. Matches how legacy authZ handled v1 identities.

User-identity URIs (/v<N>/user/...) are still rejected for both versions — they must never be promoted to a service principal. Other v1 paths (e.g. v1/wf/ workflow) fall through to URN/DN since they are not in scope for ZK.

Test testRealCertWithSpiffeV1WlUriSanIsExtracted builds a real BouncyCastle-signed cert with a v1/wl SAN and asserts the extracted principal is the bare app-name. All 30 SPIFFE-related tests pass locally.


Update (2026-07-14): Re-checked against the full LISPIFFE-ID spec — §2.A also lists application/<...> and airflow/<....> as valid v1 workload sub-types alongside wl/, which weren't handled yet. Added support for both:

  • spiffe://<td>/v1/application/<path> → principal is the full path with the type prefix retained, e.g. application/foo-mp/bar-app (same semantics as v2).
  • spiffe://<td>/v1/airflow/<path> → same, e.g. airflow/my-dag.

v1/wf/ (Flyte workflow) remains explicitly out of scope, as you noted. All 43 SPIFFE-related tests pass locally (X509AuthTest 22, X509SpiffeAuthIntegrationTest 10, ZkClientUriDomainMappingHelperTest 11).


// Matches the legacy LISPIFFE v1 "wl/<app-name>" workload form and captures the app-name.
// Per LISPIFFE-ID spec, the v1 workload unique-identity is "wl/<app-name>"; 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;
Expand Down Expand Up @@ -131,6 +166,20 @@ 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<String> 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)) {
Expand All @@ -140,10 +189,128 @@ public static String getClientId(X509Certificate clientCert) {
LOG.warn("Failed to match and extract a client ID from SAN. Using Subject DN instead.", ce);
}
}
// return Subject DN by default
return clientCert.getSubjectX500Principal().getName();
}

/**
* Attempt to extract a client identity from a LISPIFFE URI SAN. Always active — not gated
* behind any operator configuration. Any URI SAN beginning with {@code spiffe://} is treated
* as a candidate. Supported forms:
* <ul>
* <li><b>v2</b> ({@code spiffe://<td>/v2/<path>}): 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.</li>
* <li><b>v1 workload, {@code wl} form</b> ({@code spiffe://<td>/v1/wl/<app-name>}): principal is
* just the {@code <app-name>} (the "wl/" type prefix is stripped, matching how legacy authZ
* handled v1 identities).</li>
* <li><b>v1 workload, {@code application}/{@code airflow} forms</b>
* ({@code spiffe://<td>/v1/application/<path>} or {@code spiffe://<td>/v1/airflow/<path>}):
* principal is the full path including the type prefix, e.g.
* {@code spiffe://<td>/v1/application/foo-mp/bar-app} → {@code application/foo-mp/bar-app}
* (see LISPIFFE-ID spec §2.A).</li>
* </ul>
*
* <p>Returns {@link Optional#empty()} when no URI SAN begins with {@code spiffe://}, the
* matched URI is a user identity ({@code /v<N>/user/...}, which must never be promoted to a
* 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://}
*/
private static Optional<String> matchAndExtractSpiffeSAN(X509Certificate clientCert)
throws CertificateParsingException {
String spiffeUri = findSingleMatchingSan(clientCert, 6, SPIFFE_URI_PATTERN, "SPIFFE");
if (spiffeUri == null) {
return Optional.empty();
}

String path;
try {
// getRawPath() returns the literal (un-percent-decoded) path so the principal we accept is
// exactly what the CA validated in the SAN. getPath() would decode %2F → /, allowing a
// single-segment SAN like /v2/foo%2Fbar to be promoted to a multi-segment principal that
// could collide with an unrelated registered identity. Reject any path containing % to
// also block encoded "user" bypass (e.g. /v2/%75ser/alice).
path = URI.create(spiffeUri).getRawPath();
} catch (IllegalArgumentException e) {
LOG.debug("Malformed SPIFFE URI '{}'; falling through to URN/DN extraction.", spiffeUri);
return Optional.empty();
}
if (path == null) {
return Optional.empty();
}
if (path.indexOf('%') >= 0) {
LOG.debug("Rejecting SPIFFE URI with percent-encoded path '{}'; falling through.", spiffeUri);
return Optional.empty();
}
if (SPIFFE_USER_IDENTITY_PATH_PATTERN.matcher(path).matches()) {
LOG.debug("Rejecting SPIFFE user identity '{}' for service-principal extraction.", spiffeUri);
return Optional.empty();
}
Matcher v2Matcher = SPIFFE_V2_PATH_PATTERN.matcher(path);
if (v2Matcher.matches()) {
return Optional.of(v2Matcher.group(1));
}
Matcher v1WlMatcher = SPIFFE_V1_WL_PATH_PATTERN.matcher(path);
if (v1WlMatcher.matches()) {
return Optional.of(v1WlMatcher.group(1));
}
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();
}

/**
* 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<List<?>> 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.
Expand Down Expand Up @@ -204,18 +371,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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()));
Expand Down
Loading
Loading