diff --git a/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md b/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md index d015079d7dc..ac966ee5eee 100644 --- a/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md +++ b/zookeeper-docs/src/main/resources/markdown/zookeeperAdmin.md @@ -1368,6 +1368,50 @@ 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 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 **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. + + URI-domain and direct ACL compatibility does not require this option. + That compatibility is one-way: eligible SPIFFE application identities may + 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 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. + + 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/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/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 179eac8dfbf..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 @@ -62,6 +62,14 @@ public interface AuthenticationProvider { */ boolean matches(String id, String aclExpr); + /** + * Context-aware matching for providers that need authenticated request identity. + * Existing providers retain their string-only matching behavior. + */ + default boolean matches(ServerAuthenticationProvider.ServerObjs serverObjs, 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..65914f5dc97 --- /dev/null +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/LegacyServicePrincipalMatcher.java @@ -0,0 +1,85 @@ +/* + * 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.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.CertificateType; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; + +/** + * 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 = + Pattern.compile("^(?:urn:li:)?servicePrincipal\\(([^();/]+)(?:\\)|;[^()/]*\\))?$"); + private static final Pattern SPIFFE_APPLICATION_PATTERN = + Pattern.compile("^application/[^/]+/([^/]+)(?:/[^/]+)?$"); + + 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 legacyId) { + String applicationName = getApplicationName(legacyId); + 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(ClientIdentity identity, String authenticatedId, String aclId) { + // Bind the candidate AuthInfo ID to the authenticated certificate identity, not a mapped domain. + return identity != null && identity.getId().equals(authenticatedId) + && matches(identity.getCertificateType(), authenticatedId, aclId); + } + + private static String getApplicationName(String legacyId) { + if (legacyId == null) { + return null; + } + Matcher matcher = LEGACY_SERVICE_PRINCIPAL_PATTERN.matcher(legacyId); + return matcher.matches() ? matcher.group(1) : null; + } +} 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 65dc4376bb0..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,11 +55,11 @@ public KeeperException.Code handleAuthentication(ServerObjs serverObjs, byte[] a /** * {@inheritDoc} * - * forwards to older method {@link #matches(String, String)} + * forwards request context while preserving legacy providers' default behavior */ @Override public boolean matches(ServerObjs serverObjs, MatchValues matchValues) { - return implementation.matches(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/X509AuthenticationConfig.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/X509AuthenticationConfig.java index 5d4eb9b40b7..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 @@ -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 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 = + 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) @@ -83,6 +89,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; @@ -271,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)); @@ -482,4 +493,5 @@ public static void reset() { instance = null; } } + } 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..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 @@ -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; @@ -28,6 +30,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,14 +94,20 @@ 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); + 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)); - 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); @@ -118,6 +127,13 @@ public boolean matches(String id, String aclExpr) { return id.equals(aclExpr); } + @Override + public boolean matches(ServerAuthenticationProvider.ServerObjs serverObjs, String id, String aclExpr) { + return matches(id, aclExpr) + || LegacyServicePrincipalMatcher.matchesAuthenticatedClient( + serverObjs == null ? null : serverObjs.getX509ClientIdentity(), id, aclExpr); + } + @Override public boolean isAuthenticated() { return true; 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..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 @@ -18,11 +18,14 @@ 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.Objects; +import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -48,6 +51,65 @@ 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; + + 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. + 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. + // See LISPIFFE-ID spec: https://github.com/linkedin-multiproduct/gopki/blob/master/LISPIFFE-ID.md + private static final Pattern SPIFFE_USER_IDENTITY_PATH_PATTERN = + Pattern.compile("^/v\\d+/user(/.*)?$"); + + // Matches LISPIFFE v2 paths and captures the ILM UID (the path after "/v2/"). + // The canonical ILM v2 principal is the full path-after-v2 (e.g. "application/foo-mp/bar-app"); + // ACL matching downstream is segment-prefix on this UID. + private static final Pattern SPIFFE_V2_PATH_PATTERN = Pattern.compile("^/v2/(.+)$"); + + // Matches 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; @@ -125,36 +187,170 @@ 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); + if (spiffeId.isPresent()) { + LOG.debug("Extracted SPIFFE identity: {}", spiffeId.get().getId()); + 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 { - 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 Subject DN by default - return clientCert.getSubjectX500Principal().getName(); + return new ClientIdentity(CertificateType.SUBJECT_DN, 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: + *
    + *
  • 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 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/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 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(new ClientIdentity(CertificateType.SPIFFE_V2, v2Matcher.group(1))); + } + Matcher v1WlMatcher = SPIFFE_V1_WL_PATH_PATTERN.matcher(path); + if (v1WlMatcher.matches()) { + 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(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(); + } + + /** + * 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. * @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); @@ -204,18 +400,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/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/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 34869b83c4f..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 @@ -19,9 +19,9 @@ 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.Optional; import java.util.Set; import java.util.stream.Collectors; import javax.net.ssl.X509KeyManager; @@ -31,9 +31,11 @@ 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; +import org.apache.zookeeper.server.auth.X509AuthenticationUtil.ClientIdentity; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -98,6 +100,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 +125,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.getX509ClientIdentity(), matchValues.getId(), matchValues.getAclExpr()); } @Override @@ -164,15 +169,21 @@ 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())); + ClientIdentity identity = X509AuthenticationUtil.getClientId(cnxn, trustManager); + cnxn.setX509ClientIdentity(identity); + 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.", 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 { @@ -204,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<>(); @@ -223,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/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..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 @@ -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; @@ -32,7 +33,9 @@ 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; import org.slf4j.LoggerFactory; @@ -44,16 +47,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 +80,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 +134,124 @@ 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()); + return getDomains(null, clientUri); + } + + /** + * After exact and segment-prefix lookup miss, SPIFFE v1/wl and v1/v2 application identities + * 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 + public Set getDomains(CertificateType certificateType, String clientUri) { + 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; + } + 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); + } + } + if (prefixMatched) { + return result.isEmpty() ? Collections.emptySet() : result; + } + } + 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; } @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/common/SpiffeAuthTestUtil.java b/zookeeper-server/src/test/java/org/apache/zookeeper/common/SpiffeAuthTestUtil.java new file mode 100644 index 00000000000..f50a96feab7 --- /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 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; + + private SpiffeAuthTestUtil() { + } + + public static void registerBouncyCastle() { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + /** Configures SAN-based extraction in the X509AuthenticationConfig singleton. */ + public static void setSpiffeSystemProperties() { + System.setProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE, "SAN"); + X509AuthenticationConfig.reset(); + } + + public static void clearSpiffeSystemProperties() { + System.clearProperty(X509AuthenticationConfig.SSL_X509_CLIENT_CERT_ID_TYPE); + 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/X509DirectAclTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java new file mode 100644 index 00000000000..faef8fb7465 --- /dev/null +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/X509DirectAclTest.java @@ -0,0 +1,593 @@ +/* + * 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.Request; +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_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, + 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 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); + assertDenied(cnxn, "kafka", ZooDefs.Perms.READ, ZooDefs.Perms.WRITE); + for (String id : Arrays.asList( + "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)")) { + 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 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); + 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"); + 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/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); + 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); + } + } + } + + @Test + public void testConfiguredSanWithSameNameDoesNotGainSpiffeCompatibility() throws Exception { + configureSan("^urn:example:(.*)$"); + 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); + if (!id.equals("kafka")) { + assertDenied(cnxn, "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); + assertDenied(legacy, "kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + assertDenied(legacy, "servicePrincipal(other", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + 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 testLegacyIdentitiesDoNotGainReverseCompatibility() throws Exception { + configureSan("^urn:li:([^;]+)"); + 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); + } + 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")); + assertDenied(legacy, "kafka", ZooDefs.Perms.ALL, ZooDefs.Perms.READ); + 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/v2/application/example-mp/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 + 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 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 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"); + 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"); + 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); + } + 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 testSuperUserCompatibilityRequiresMatchingLegacyConfig() throws Exception { + System.setProperty(X509AuthenticationConfig.SSL_X509_LEGACY_SUPER_USER_COMPATIBILITY_ENABLED, "true"); + for (String configuredId : Arrays.asList( + "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)", + "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"); + 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))); + } + + @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( + 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(); + } +} 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/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/auth/znode/groupacl/X509ZNodeGroupAclProviderTest.java index 896d8437aee..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 @@ -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,156 @@ 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 testBareSuperUserConfigurationRequiresExactIdentity() throws Exception { + System.setProperty(X509AuthenticationConfig.ZOOKEEPER_ZNODEGROUPACL_SUPERUSER_ID, "kafka"); + 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()); + } + } + + @Test + public void testExactSuperUserIdWinsBeforeCompatibleMarkers() throws Exception { + String legacyId = "servicePrincipal(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 + ")")) { + 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( + "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"); + 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 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 { + 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("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, + 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)); + + 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"); + admin.create(CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/DomainX/servicePrincipal(kafka", + null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + 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 public void testAuthInfoAutoUpdate() throws InterruptedException, KeeperException { String clientId = "DomainZUser"; @@ -352,6 +509,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 +544,3 @@ public boolean isClosed() { } } } - 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..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 @@ -18,10 +18,15 @@ 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; 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 +34,21 @@ 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.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; 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,17 +70,39 @@ public class ZkClientUriDomainMappingHelperTest extends ZKTestCase { CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/foo", CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/foo/foo1", CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/foo/foo2", - CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/foo/bar1" + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/foo/bar1", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-apps", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-apps/workload", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-apps/workload/helix-core", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-apps/workload/helix-core/helix-controller", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-apps/workload/helix-core/helix-rest", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp/workload", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp/workload/different-mp", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-legacy", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-legacy/urn:li:servicePrincipal(legacy;ei4;i001)", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp-grant", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp-grant/application", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/helix-mp-grant/application/helix-core", + 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/kafka" }; 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..."); - 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); @@ -157,6 +191,261 @@ 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(); + } + } + + /** + * 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 + 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", + CLIENT_URI_DOMAIN_MAPPING_ROOT_PATH + "/broker-access/servicePrincipal(kafka" + }) { + zookeeperClientConnection.create(path, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); + } + X509ZNodeGroupAclProvider provider = new X509ZNodeGroupAclProvider( + new SpiffeAuthTestUtil.AcceptAllTrustManager(), new SpiffeAuthTestUtil.NoopKeyManager()); + 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(clientId, cnxn.getX509ClientIdentity().getId()); + + 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 + 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 + 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)) { + 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", 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(legacyMappingPath, -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 @@ -167,4 +456,270 @@ 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)); + } + + @Test + public void testD_SpiffeWorkloadMatchesLegacyApplicationNames() { + 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")); + 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")), + helper.getDomains(type, clientId)); + } + } + 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")); + 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 + 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); + + 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.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("kafka", Collections.singleton("bare-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); + 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)) { + 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); + + 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_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"))); + 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(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)); + } + } + + @Test + 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)", + "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")); + 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"), + 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/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; + } +} 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..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; @@ -131,6 +134,153 @@ public void testSANBasedAuth() { X509AuthenticationConfig.reset(); } + @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"); + // SPIFFE detection is always on; this cert has no spiffe:// SAN, so it's unaffected. + + 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); + X509AuthenticationConfig.reset(); + } + } + + @Test + public void testUrnWithoutSanConfigurationKeepsSubjectDn() { + 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[]{serviceCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + 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 testUrnWithMetadataWithoutSanConfigurationKeepsSubjectDn() { + String servicePrincipalSan = "urn:li:servicePrincipal(kafka;region1;instance1)"; + String servicePrincipalMetadataSan = "urn:li:servicePrincipalMetadata(dev;1.0.0)"; + TestCertificate serviceCert = new TestCertificate("CLIENT", + Arrays.asList(servicePrincipalSan, servicePrincipalMetadataSan)); + X509AuthenticationProvider provider = createProvider(serviceCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{serviceCert}; + + assertEquals(KeeperException.Code.OK, provider.handleAuthentication(cnxn, null)); + assertEquals("CN=CLIENT", cnxn.getAuthInfo().get(0).getId()); + assertEquals(CertificateType.SUBJECT_DN, + X509AuthenticationUtil.getClientId(serviceCert).getCertificateType()); + } + + @Test + 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"); + 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 serviceCert = new TestCertificate("CLIENT", servicePrincipalSan); + X509AuthenticationProvider provider = createProvider(serviceCert); + MockServerCnxn cnxn = new MockServerCnxn(); + cnxn.clientChain = new X509Certificate[]{serviceCert}; + + 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); + 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 testClientIdentityPreservesSubjectDn() { + 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()); + assertEquals(CertificateType.SUBJECT_DN, + X509AuthenticationUtil.getClientId(certWithoutUrnSan).getCertificateType()); + } + protected static class TestPublicKey implements PublicKey { private static final long serialVersionUID = 1L; @@ -155,17 +305,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 +427,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..4f653ab8be1 --- /dev/null +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/X509SpiffeAuthIntegrationTest.java @@ -0,0 +1,390 @@ +/* + * 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 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.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; + +/** + * 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 { + + @BeforeClass + public static void registerBouncyCastle() { + SpiffeAuthTestUtil.registerBouncyCastle(); + } + + @After + 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(); + // 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("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(); + // 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(); + 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 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 + // 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 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(); + 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); + } + + @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); + 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); + 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); + 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); + 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 + * 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(); + } +}