Skip to content

Commit

Permalink
feat: [Destinations] Support Zero Trust Identity Service (#332)
Browse files Browse the repository at this point in the history
  • Loading branch information
MatKuhr authored Mar 28, 2024
1 parent 9d1531f commit d2fb4fe
Show file tree
Hide file tree
Showing 20 changed files with 910 additions and 11 deletions.
21 changes: 16 additions & 5 deletions cloudplatform/connectivity-oauth/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@
<groupId>com.sap.cloud.sdk.cloudplatform</groupId>
<artifactId>cloudplatform-connectivity</artifactId>
</dependency>
<dependency>
<groupId>com.sap.cloud.sdk.cloudplatform</groupId>
<artifactId>connectivity-ztis</artifactId>
<!-- connectivity-ztis brings some dependencies we don't want for applications that don't need it.
Setting this to optional means ztis won't come transitively when including oauth.
Users will have to include ztis manually. -->
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.sap.cloud.sdk.cloudplatform</groupId>
<artifactId>connectivity-apache-httpclient4</artifactId>
</dependency>
<dependency>
<groupId>com.sap.cloud.sdk.cloudplatform</groupId>
<artifactId>security</artifactId>
Expand Down Expand Up @@ -122,6 +134,10 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- scope "provided" -->
<dependency>
<groupId>org.projectlombok</groupId>
Expand All @@ -139,11 +155,6 @@
<artifactId>java-access-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.sap.cloud.sdk.cloudplatform</groupId>
<artifactId>connectivity-apache-httpclient4</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.sap.cloud.sdk.cloudplatform.connectivity.BtpServiceOptions.BusinessLoggingOptions;
import com.sap.cloud.sdk.cloudplatform.connectivity.BtpServiceOptions.BusinessRulesOptions;
import com.sap.cloud.sdk.cloudplatform.connectivity.BtpServiceOptions.WorkflowOptions;
import com.sap.cloud.sdk.cloudplatform.connectivity.SecurityLibWorkarounds.ZtisClientIdentity;
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
import com.sap.cloud.sdk.cloudplatform.tenant.Tenant;
import com.sap.cloud.sdk.cloudplatform.tenant.TenantAccessor;
Expand Down Expand Up @@ -247,6 +248,9 @@ private void attachClientKeyStore( @Nonnull final OAuth2Options.Builder optionsB
private KeyStore getClientKeyStore()
{
final ClientIdentity clientIdentity = getClientIdentity();
if( clientIdentity instanceof ZtisClientIdentity ) {
return ((ZtisClientIdentity) clientIdentity).getKeyStore();
}
if( !(clientIdentity instanceof ClientCertificate) ) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

package com.sap.cloud.sdk.cloudplatform.connectivity;

import static com.sap.cloud.sdk.cloudplatform.connectivity.SecurityLibWorkarounds.X509_ATTESTED;

import java.net.URI;
import java.net.URISyntaxException;
import java.security.KeyStore;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
Expand All @@ -16,7 +19,9 @@

import com.google.common.annotations.Beta;
import com.sap.cloud.environment.servicebinding.api.TypedMapView;
import com.sap.cloud.sdk.cloudplatform.connectivity.SecurityLibWorkarounds.ZtisClientIdentity;
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
import com.sap.cloud.sdk.cloudplatform.exception.CloudPlatformException;
import com.sap.cloud.security.config.ClientCertificate;
import com.sap.cloud.security.config.ClientCredentials;
import com.sap.cloud.security.config.ClientIdentity;
Expand Down Expand Up @@ -132,11 +137,43 @@ protected List<String> getOAuthPropertyPath()
ClientIdentity getCertificateIdentity()
{
final String clientid = getOAuthCredentialOrThrow(String.class, "clientid");

final Option<String> exactCredentialType = getOAuthCredential(String.class, "credential-type");
if( exactCredentialType.contains(X509_ATTESTED) ) {
return getZtisClientIdentity(clientid);
}

final String cert = getOAuthCredentialOrThrow(String.class, "certificate");
final String key = getOAuthCredentialOrThrow(String.class, "key");
return new ClientCertificate(cert, key, clientid);
}

@Nonnull
private ZtisClientIdentity getZtisClientIdentity( @Nonnull final String clientid )
{
try {
// sanity check: assert the connectivity-ztis module is present
getClass()
.getClassLoader()
.loadClass("com.sap.cloud.sdk.cloudplatform.connectivity.ZeroTrustIdentityService");
}
catch( final ClassNotFoundException e ) {
throw new CloudPlatformException(
"Failed to load implementation for credential type X509_ATTESTED. Please ensure the 'connectivity-ztis' module is present.",
e);
}
final ZeroTrustIdentityService ztis = ZeroTrustIdentityService.getInstance();

final KeyStore keyStore;
try {
keyStore = ztis.getOrCreateKeyStore();
}
catch( final Exception e ) {
throw new CloudPlatformException("Failed to load X509 certificate for credential type X509_ATTESTED.", e);
}
return new ZtisClientIdentity(clientid, keyStore);
}

@Nonnull
ClientIdentity getSecretIdentity()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ public class IdentityAuthenticationServiceBindingDestinationLoader implements Se
private static final String PROPERTY_TYPE_MISMATCH_WITH_FALLBACK_TEMPLATE =
"The '{}' attribute of the IAS-based service binding is expected to be an instance of {}, which is not the case. The fallback value will be used instead.";

private static final ServiceIdentifier NULL_IDENTIFIER = ServiceIdentifier.of("unknown-service");
@Nonnull
private final ServiceBindingDestinationLoader delegateLoader;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@
import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import org.apache.http.impl.client.CloseableHttpClient;

import com.auth0.jwt.interfaces.DecodedJWT;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.sap.cloud.environment.servicebinding.api.ServiceIdentifier;
import com.sap.cloud.sdk.cloudplatform.cache.CacheKey;
import com.sap.cloud.sdk.cloudplatform.cache.CacheManager;
import com.sap.cloud.sdk.cloudplatform.connectivity.SecurityLibWorkarounds.ZtisClientIdentity;
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationOAuthTokenException;
import com.sap.cloud.sdk.cloudplatform.exception.CloudPlatformException;
Expand Down Expand Up @@ -55,7 +58,6 @@
@Slf4j
class OAuth2Service
{

/**
* Cache to reuse OAuth2TokenService and with that reuse the underlying response cache.
* <p>
Expand Down Expand Up @@ -97,7 +99,30 @@ class OAuth2Service
OAuth2TokenService getTokenService( @Nullable final String tenantId )
{
final CacheKey key = CacheKey.fromIds(tenantId, null).append(identity);
return tokenServiceCache.get(key, x -> new DefaultOAuth2TokenService(HttpClientFactory.create(identity)));
return tokenServiceCache.get(key, this::createTokenService);
}

@Nonnull
private OAuth2TokenService createTokenService( @Nonnull final CacheKey ignored )
{
if( !(identity instanceof ZtisClientIdentity) ) {
return new DefaultOAuth2TokenService(HttpClientFactory.create(identity));
}

final DefaultHttpDestination destination =
DefaultHttpDestination
.builder(tokenUri)
.name("oauth-destination-ztis-" + identity.getId().hashCode())
.keyStore(((ZtisClientIdentity) identity).getKeyStore())
.build();
try {
return new DefaultOAuth2TokenService((CloseableHttpClient) HttpClientAccessor.getHttpClient(destination));
}
catch( final ClassCastException e ) {
final String msg =
"For the X509_ATTESTED credential type the 'HttpClientAccessor' must return instances of 'CloseableHttpClient'";
throw new DestinationAccessException(msg, e);
}
}

@Nonnull
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
package com.sap.cloud.sdk.cloudplatform.connectivity;

import static com.sap.cloud.sdk.cloudplatform.connectivity.DestinationKeyStoreComparator.resolveCertificatesOnly;
import static com.sap.cloud.sdk.cloudplatform.connectivity.DestinationKeyStoreComparator.resolveKeyStoreHashCode;

import java.security.KeyStore;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;

import com.sap.cloud.security.config.ClientIdentity;
import com.sap.cloud.security.config.CredentialType;

import lombok.AllArgsConstructor;
import lombok.Getter;

final class SecurityLibWorkarounds
{
private static final String X509_GENERATED = "X509_GENERATED";
static final String X509_ATTESTED = "X509_ATTESTED";

private SecurityLibWorkarounds()
{
Expand All @@ -17,11 +30,52 @@ private SecurityLibWorkarounds()
@Nullable
static CredentialType getCredentialType( @Nonnull final String rawType )
{
if( rawType.equals(X509_GENERATED) ) {
// this particular credential type is currently (2024-01-31) NOT supported by the Security Client Lib.
if( rawType.equals(X509_GENERATED) || rawType.equals(X509_ATTESTED) ) {
// these particular credential types are only supported by the Security Client Lib > 3.3.5
return CredentialType.X509;
}

return CredentialType.from(rawType);
}

@Getter
@AllArgsConstructor
static class ZtisClientIdentity implements ClientIdentity
{
@Nonnull
private final String id;
@Nonnull
private final KeyStore keyStore;

@Override
public boolean isCertificateBased()
{
return true;
}

// The identity will be used as cache key, so it's important we correctly implement equals/hashCode
@Override
public boolean equals( final Object obj )
{
if( this == obj ) {
return true;
}

if( obj == null || getClass() != obj.getClass() ) {
return false;
}

final ZtisClientIdentity that = (ZtisClientIdentity) obj;
return new EqualsBuilder()
.append(id, that.id)
.append(resolveCertificatesOnly(keyStore), resolveCertificatesOnly(that.keyStore))
.isEquals();
}

@Override
public int hashCode()
{
return new HashCodeBuilder(41, 71).append(id).append(resolveKeyStoreHashCode(keyStore)).build();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@

import com.sap.cloud.environment.servicebinding.api.ServiceBinding;
import com.sap.cloud.environment.servicebinding.api.ServiceIdentifier;
import com.sap.cloud.environment.servicebinding.api.exception.ServiceBindingAccessException;
import com.sap.cloud.sdk.cloudplatform.connectivity.BtpServiceOptions.BusinessLoggingOptions;
import com.sap.cloud.sdk.cloudplatform.connectivity.BtpServiceOptions.BusinessRulesOptions;
import com.sap.cloud.sdk.cloudplatform.connectivity.BtpServiceOptions.WorkflowOptions;
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
import com.sap.cloud.sdk.cloudplatform.exception.CloudPlatformException;
import com.sap.cloud.sdk.cloudplatform.tenant.DefaultTenant;
import com.sap.cloud.sdk.cloudplatform.tenant.TenantAccessor;

Expand Down Expand Up @@ -268,6 +270,7 @@ void testAiCore()

final OAuth2PropertySupplier sut = AI_CORE.resolve(options);

assertThat(sut).isNotNull();
assertThat(sut.getServiceUri())
.isEqualTo(URI.create("https://api.ai.internalprod.eu-central-1.aws.ml.hana.ondemand.com"));
assertThat(sut.getClientIdentity().getId()).isEqualTo("client-id");
Expand Down Expand Up @@ -551,6 +554,30 @@ void testMutualTlsCanBeCombinedWithTokenRetrievalOptions()
assertThat(tokenRetrievalOptions.getAdditionalTokenRetrievalParameters()).isNotEmpty();
}

@Test
@DisplayName( "Test the credential type X509_ATTESTED" )
void testMutualTlsWithZeroTrustIdentityService()
{
final ServiceBinding binding =
bindingWithCredentials(
ServiceIdentifier.IDENTITY_AUTHENTICATION,
entry("app_tid", PROVIDER_TENANT_ID),
entry("url", PROVIDER_URL),
entry("credential-type", "X509_ATTESTED"),
entry("clientid", "ias-client-id"));

final ServiceBindingDestinationOptions options =
ServiceBindingDestinationOptions.forService(binding).build();

final OAuth2PropertySupplier sut = IDENTITY_AUTHENTICATION.resolve(options);
assertThat(sut).isNotNull();

assertThatThrownBy(sut::getClientIdentity)
.isInstanceOf(CloudPlatformException.class)
.describedAs("We are not mocking the ZTIS service here so this should fail")
.hasRootCauseInstanceOf(ServiceBindingAccessException.class);
}

@Test
void testMutuallyExclusiveOptions()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
import com.sap.cloud.environment.servicebinding.api.DefaultServiceBinding;
import com.sap.cloud.environment.servicebinding.api.ServiceBinding;
import com.sap.cloud.environment.servicebinding.api.ServiceIdentifier;
import com.sap.cloud.environment.servicebinding.api.exception.ServiceBindingAccessException;
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
import com.sap.cloud.sdk.cloudplatform.exception.CloudPlatformException;
import com.sap.cloud.security.config.ClientCertificate;
import com.sap.cloud.security.config.CredentialType;

Expand Down Expand Up @@ -150,6 +152,25 @@ void testCredentialTypeX509()
});
}

@Test
void testCredentialTypeX509_ATTESTED()
{
final ServiceBinding binding =
new ServiceBindingBuilder(ServiceIdentifier.of("testX509_attested"))
.with("credentials.uaa.credential-type", "X509_ATTESTED")
.with("credentials.uaa.clientid", "id")
.build();
final ServiceBindingDestinationOptions options = ServiceBindingDestinationOptions.forService(binding).build();

sut = new DefaultOAuth2PropertySupplier(options);

assertThat(sut.getCredentialType()).isEqualTo(CredentialType.X509);
assertThatThrownBy(sut::getClientIdentity)
.isInstanceOf(CloudPlatformException.class)
.describedAs("We are not mocking the Zero Trust Identity Service here, so this should be a failure")
.hasRootCauseInstanceOf(ServiceBindingAccessException.class);
}

@RequiredArgsConstructor
private static final class ServiceBindingBuilder
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import static org.mockito.Mockito.spy;

import java.net.URI;
import java.security.KeyStore;
import java.time.Duration;
import java.util.List;
import java.util.Map;
Expand All @@ -30,6 +31,7 @@
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import com.github.tomakehurst.wiremock.stubbing.ServeEvent;
import com.sap.cloud.sdk.cloudplatform.cache.CacheManager;
import com.sap.cloud.sdk.cloudplatform.connectivity.SecurityLibWorkarounds.ZtisClientIdentity;
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationOAuthTokenException;
import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceConfiguration;
import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceIsolationMode;
Expand Down Expand Up @@ -279,4 +281,19 @@ void testCacheIsRegistered()

assertThat(CacheManager.getCacheList()).contains(OAuth2Service.tokenServiceCache);
}

@Test
void testZeroTrustClientIdentity()
{
ClientIdentity identity = new ZtisClientIdentity("id", mock(KeyStore.class));
OAuth2Service service = OAuth2Service.builder().withTokenUri(SERVER_1.baseUrl()).withIdentity(identity).build();

final OAuth2TokenService result = service.getTokenService(null);
assertThat(result).isSameAs(service.getTokenService(null));

identity = new ZtisClientIdentity("other-id", mock(KeyStore.class));
service = OAuth2Service.builder().withTokenUri(SERVER_1.baseUrl()).withIdentity(identity).build();

assertThat(result).isNotSameAs(service.getTokenService(null));
}
}
Loading

0 comments on commit d2fb4fe

Please sign in to comment.