From 4d82889234046c568f134b78fb11be6ac8569bf1 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 4 Aug 2026 13:41:28 +0200 Subject: [PATCH 1/4] Make selection of svid deterministic and add test --- .../ZeroTrustIdentityService.java | 39 +++++++++++++++++-- .../ZeroTrustIdentityServiceTest.java | 34 ++++++++++++++++ release_notes.md | 2 +- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java b/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java index 842b49cba2..92130ecc4c 100644 --- a/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java +++ b/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java @@ -10,6 +10,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Date; +import java.util.List; import java.util.Objects; import javax.annotation.Nonnull; @@ -23,6 +24,7 @@ import io.spiffe.bundle.x509bundle.X509Bundle; import io.spiffe.exception.X509SvidException; +import io.spiffe.spiffeid.SpiffeId; import io.spiffe.spiffeid.TrustDomain; import io.spiffe.svid.x509svid.X509Svid; import io.spiffe.workloadapi.DefaultX509Source; @@ -109,16 +111,47 @@ X509Source initX509Source() final String socketPath = Option.of(System.getenv(SOCKET_ENVIRONMENT_VARIABLE)).getOrElse(DEFAULT_SOCKET_PATH); log.info("Using socket path {} for ZTIS agent.", socketPath); - final X509SourceOptions x509SourceOptions = - X509SourceOptions.builder().spiffeSocketPath(socketPath).initTimeout(DEFAULT_SOCKET_TIMEOUT).build(); + // The SPIRE agent may return multiple SVIDs when overlapping registration selectors exist + // (e.g. a second service key on the same ZTIS instance, or a co-located workload). We must pick + // the one whose SPIFFE ID matches our binding — see pickSvid. + final SpiffeId expectedSpiffeId = SpiffeId.parse(mapView.getMapView("workload").getString("spiffeID")); + final X509SourceOptions options = + X509SourceOptions + .builder() + .spiffeSocketPath(socketPath) + .initTimeout(DEFAULT_SOCKET_TIMEOUT) + .svidPicker(svids -> pickSvid(svids, expectedSpiffeId)) + .build(); try { - return DefaultX509Source.newSource(x509SourceOptions); + return DefaultX509Source.newSource(options); } catch( final Exception e ) { throw new CloudPlatformException("Failed to load the certificate from the unix socket: " + socketPath, e); } } + /** + * Selects the {@link X509Svid} whose SPIFFE ID equals {@code expectedSpiffeId} from the given list. Used as the + * {@code svidPicker} for {@link DefaultX509Source} to avoid non-deterministic selection when the SPIRE agent + * returns multiple SVIDs (e.g. after a second service key is created for the same ZTIS instance). + */ + @Nonnull + static X509Svid pickSvid( @Nonnull final List svids, @Nonnull final SpiffeId expectedSpiffeId ) + { + log.debug("SPIRE agent returned {} SVID(s); selecting the one matching '{}'.", svids.size(), expectedSpiffeId); + return svids + .stream() + .filter(svid -> expectedSpiffeId.equals(svid.getSpiffeId())) + .findFirst() + .orElseThrow( + () -> new CloudPlatformException( + "No SVID matching SPIFFE ID '" + + expectedSpiffeId + + "' among " + + svids.size() + + " returned by the SPIRE agent.")); + } + @Nonnull X509Svid getX509Svid() { diff --git a/cloudplatform/connectivity-ztis/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityServiceTest.java b/cloudplatform/connectivity-ztis/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityServiceTest.java index b9e064853d..8ada14b3da 100644 --- a/cloudplatform/connectivity-ztis/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityServiceTest.java +++ b/cloudplatform/connectivity-ztis/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityServiceTest.java @@ -15,6 +15,7 @@ import java.security.cert.X509Certificate; import java.time.Instant; import java.util.Date; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -26,6 +27,7 @@ import com.sap.cloud.environment.servicebinding.api.exception.ServiceBindingAccessException; import com.sap.cloud.sdk.cloudplatform.exception.CloudPlatformException; +import io.spiffe.spiffeid.SpiffeId; import io.spiffe.svid.x509svid.X509Svid; class ZeroTrustIdentityServiceTest @@ -152,6 +154,31 @@ void testCertOnFileSystem() .isInstanceOf(IllegalStateException.class); } + @Test + void testPickSvidSelectsMatchingSvid() + { + final SpiffeId expected = SpiffeId.parse("spiffe://example.org/my-app"); + final X509Svid own = mockSvidWithSpiffeId(expected); + final X509Svid other = mockSvidWithSpiffeId(SpiffeId.parse("spiffe://example.org/other-app")); + + assertThat(ZeroTrustIdentityService.pickSvid(List.of(own), expected)).isSameAs(own); + assertThat(ZeroTrustIdentityService.pickSvid(List.of(other, own), expected)).isSameAs(own); + assertThat(ZeroTrustIdentityService.pickSvid(List.of(own, other), expected)).isSameAs(own); + } + + @Test + void testPickSvidThrowsWhenNoMatch() + { + final SpiffeId expected = SpiffeId.parse("spiffe://example.org/my-app"); + final X509Svid other = mockSvidWithSpiffeId(SpiffeId.parse("spiffe://example.org/other-app")); + + assertThatThrownBy(() -> ZeroTrustIdentityService.pickSvid(List.of(other), expected)) + .isInstanceOf(CloudPlatformException.class) + .hasMessageContaining("spiffe://example.org/my-app"); + assertThatThrownBy(() -> ZeroTrustIdentityService.pickSvid(List.of(), expected)) + .isInstanceOf(CloudPlatformException.class); + } + private void mockSvid( Instant notAfter ) { final X509Svid svid = mock(X509Svid.class); @@ -162,6 +189,13 @@ private void mockSvid( Instant notAfter ) svidMock = svid; } + private static X509Svid mockSvidWithSpiffeId( final SpiffeId spiffeId ) + { + final X509Svid svid = mock(X509Svid.class); + doReturn(spiffeId).when(svid).getSpiffeId(); + return svid; + } + private static ServiceBinding mockBinding() { return new DefaultServiceBindingBuilder() diff --git a/release_notes.md b/release_notes.md index 8fb3414093..78264dbc2b 100644 --- a/release_notes.md +++ b/release_notes.md @@ -20,4 +20,4 @@ ### 🐛 Fixed Issues -- +- [Connectivity] Fixed a bug in `ZeroTrustIdentityService` where a non-deterministic SVID could be selected when the SPIRE agent returns multiple SVIDs to the workload (e.g. after creating a second service key for the same ZTIS instance). From 61c94c16aab6734024fbab3819264b7f215cf9db Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 4 Aug 2026 13:44:38 +0200 Subject: [PATCH 2/4] Improve error message --- .../connectivity/ZeroTrustIdentityService.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java b/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java index 92130ecc4c..9a2b9c9a69 100644 --- a/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java +++ b/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java @@ -145,11 +145,11 @@ static X509Svid pickSvid( @Nonnull final List svids, @Nonnull final Sp .findFirst() .orElseThrow( () -> new CloudPlatformException( - "No SVID matching SPIFFE ID '" - + expectedSpiffeId - + "' among " - + svids.size() - + " returned by the SPIRE agent.")); + String + .format( + "No SVID matching SPIFFE ID '%s' among %d returned by the SPIRE agent.", + expectedSpiffeId, + svids.size()))); } @Nonnull From add323f2b368af776eff18725df61bcaea7b33e2 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal <36329474+rpanackal@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:55:27 +0200 Subject: [PATCH 3/4] Update cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java Co-authored-by: Jonas-Isr --- .../connectivity/ZeroTrustIdentityService.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java b/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java index 9a2b9c9a69..7c572893ff 100644 --- a/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java +++ b/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java @@ -114,7 +114,12 @@ X509Source initX509Source() // The SPIRE agent may return multiple SVIDs when overlapping registration selectors exist // (e.g. a second service key on the same ZTIS instance, or a co-located workload). We must pick // the one whose SPIFFE ID matches our binding — see pickSvid. - final SpiffeId expectedSpiffeId = SpiffeId.parse(mapView.getMapView("workload").getString("spiffeID")); + final SpiffeId expectedSpiffeId; + try { + expectedSpiffeId = SpiffeId.parse(mapView.getMapView("workload").getString("spiffeID")); + } catch (Exception e) { + throw new CloudPlatformException("Invalid SPIFFE ID in Zero Trust Identity Service binding.", e); + } final X509SourceOptions options = X509SourceOptions .builder() From 6419fb8cd401ee33a5d26197fbe4dad30454062a Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 4 Aug 2026 15:37:40 +0200 Subject: [PATCH 4/4] extract buildX509SourceOptions and consolidate picker tests --- .../ZeroTrustIdentityService.java | 23 +++++++++++-------- .../ZeroTrustIdentityServiceTest.java | 23 ++++++++----------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java b/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java index 7c572893ff..8d5806bf09 100644 --- a/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java +++ b/cloudplatform/connectivity-ztis/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityService.java @@ -117,24 +117,29 @@ X509Source initX509Source() final SpiffeId expectedSpiffeId; try { expectedSpiffeId = SpiffeId.parse(mapView.getMapView("workload").getString("spiffeID")); - } catch (Exception e) { + } + catch( Exception e ) { throw new CloudPlatformException("Invalid SPIFFE ID in Zero Trust Identity Service binding.", e); } - final X509SourceOptions options = - X509SourceOptions - .builder() - .spiffeSocketPath(socketPath) - .initTimeout(DEFAULT_SOCKET_TIMEOUT) - .svidPicker(svids -> pickSvid(svids, expectedSpiffeId)) - .build(); try { - return DefaultX509Source.newSource(options); + return DefaultX509Source.newSource(buildX509SourceOptions(socketPath, expectedSpiffeId)); } catch( final Exception e ) { throw new CloudPlatformException("Failed to load the certificate from the unix socket: " + socketPath, e); } } + X509SourceOptions + buildX509SourceOptions( @Nonnull final String socketPath, @Nonnull final SpiffeId expectedSpiffeId ) + { + return X509SourceOptions + .builder() + .spiffeSocketPath(socketPath) + .initTimeout(DEFAULT_SOCKET_TIMEOUT) + .svidPicker(svids -> pickSvid(svids, expectedSpiffeId)) + .build(); + } + /** * Selects the {@link X509Svid} whose SPIFFE ID equals {@code expectedSpiffeId} from the given list. Used as the * {@code svidPicker} for {@link DefaultX509Source} to avoid non-deterministic selection when the SPIRE agent diff --git a/cloudplatform/connectivity-ztis/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityServiceTest.java b/cloudplatform/connectivity-ztis/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityServiceTest.java index 8ada14b3da..36a4c7365a 100644 --- a/cloudplatform/connectivity-ztis/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityServiceTest.java +++ b/cloudplatform/connectivity-ztis/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/ZeroTrustIdentityServiceTest.java @@ -17,6 +17,7 @@ import java.util.Date; import java.util.List; import java.util.Map; +import java.util.function.Function; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -155,28 +156,22 @@ void testCertOnFileSystem() } @Test - void testPickSvidSelectsMatchingSvid() + void testSvidPickerIsWiredAndSelectsMatchingSvid() { final SpiffeId expected = SpiffeId.parse("spiffe://example.org/my-app"); final X509Svid own = mockSvidWithSpiffeId(expected); final X509Svid other = mockSvidWithSpiffeId(SpiffeId.parse("spiffe://example.org/other-app")); - assertThat(ZeroTrustIdentityService.pickSvid(List.of(own), expected)).isSameAs(own); - assertThat(ZeroTrustIdentityService.pickSvid(List.of(other, own), expected)).isSameAs(own); - assertThat(ZeroTrustIdentityService.pickSvid(List.of(own, other), expected)).isSameAs(own); - } - - @Test - void testPickSvidThrowsWhenNoMatch() - { - final SpiffeId expected = SpiffeId.parse("spiffe://example.org/my-app"); - final X509Svid other = mockSvidWithSpiffeId(SpiffeId.parse("spiffe://example.org/other-app")); + final Function, X509Svid> picker = + sut.buildX509SourceOptions("unix:///tmp/test.sock", expected).getSvidPicker(); - assertThatThrownBy(() -> ZeroTrustIdentityService.pickSvid(List.of(other), expected)) + assertThat(picker).isNotNull(); + assertThat(picker.apply(List.of(own))).isSameAs(own); + assertThat(picker.apply(List.of(other, own))).isSameAs(own); + assertThatThrownBy(() -> picker.apply(List.of(other))) .isInstanceOf(CloudPlatformException.class) .hasMessageContaining("spiffe://example.org/my-app"); - assertThatThrownBy(() -> ZeroTrustIdentityService.pickSvid(List.of(), expected)) - .isInstanceOf(CloudPlatformException.class); + assertThatThrownBy(() -> picker.apply(List.of())).isInstanceOf(CloudPlatformException.class); } private void mockSvid( Instant notAfter )