diff --git a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/credential/DefaultCredentialProviderService.java b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/credential/DefaultCredentialProviderService.java index 749454a2cae2..2e19fbdd521c 100644 --- a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/credential/DefaultCredentialProviderService.java +++ b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/credential/DefaultCredentialProviderService.java @@ -18,6 +18,7 @@ import com.google.common.util.concurrent.AbstractIdleService; import io.cdap.cdap.common.conf.CConfiguration; +import io.cdap.cdap.proto.NamespaceMeta; import io.cdap.cdap.proto.credential.CredentialIdentity; import io.cdap.cdap.proto.credential.CredentialProfile; import io.cdap.cdap.proto.credential.CredentialProvisioningException; @@ -33,6 +34,8 @@ import java.util.Map; import java.util.Optional; import javax.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Default implementation for {@link CredentialProviderService} used in AppFabric. @@ -40,6 +43,7 @@ public class DefaultCredentialProviderService extends AbstractIdleService implements CredentialProviderService { + private static final Logger LOG = LoggerFactory.getLogger(DefaultCredentialProviderService.class); private final CConfiguration cConf; private final ContextAccessEnforcer contextAccessEnforcer; private final Map credentialProviders; @@ -57,10 +61,21 @@ public class DefaultCredentialProviderService extends AbstractIdleService this.credentialProviders = credentialProviderLoader.loadCredentialProviders(); this.credentialIdentityManager = credentialIdentityManager; this.credentialProfileManager = credentialProfileManager; + for (Map.Entry credentialProviderEntry : + this.credentialProviders.entrySet()) { + LOG.info("Credential Provider {}, Name {}", credentialProviderEntry.getKey(), + credentialProviderEntry.getValue().getName()); + } + if (credentialProviders.size() <= 0) { + LOG.info("No credential providers found"); + } } @Override protected void startUp() throws Exception { + if (credentialProviders.size() <= 0) { + LOG.info("No credential providers found"); + } for (CredentialProvider provider : credentialProviders.values()) { provider.initialize(new DefaultCredentialProviderContext(cConf, provider.getName())); } @@ -74,7 +89,7 @@ protected void shutDown() throws Exception { /** * Provisions a credential. * - * @param namespace The identity namespace. + * @param namespaceMeta The identity namespace metadata. * @param identityName The identity name. * @return A provisioned credential. * @throws CredentialProvisioningException If provisioning fails in the extension. @@ -82,9 +97,10 @@ protected void shutDown() throws Exception { * @throws NotFoundException If the identity or profile are not found. */ @Override - public ProvisionedCredential provision(String namespace, String identityName) + public ProvisionedCredential provision(NamespaceMeta namespaceMeta, String identityName) throws CredentialProvisioningException, IOException, NotFoundException { - CredentialIdentityId identityId = new CredentialIdentityId(namespace, identityName); + CredentialIdentityId identityId = + new CredentialIdentityId(namespaceMeta.getName(), identityName); contextAccessEnforcer.enforce(identityId, StandardPermission.USE); Optional optIdentity = credentialIdentityManager.get(identityId); if (!optIdentity.isPresent()) { @@ -92,32 +108,34 @@ public ProvisionedCredential provision(String namespace, String identityName) identityId.toString())); } CredentialIdentity identity = optIdentity.get(); - return validateAndProvisionIdentity(identity); + return validateAndProvisionIdentity(namespaceMeta, identity); } /** * Validates an identity. * + * @param namespaceMeta The identity namespace metadata. * @param identity The identity to validate. * @throws IdentityValidationException If identity validation fails in the extension. * @throws IOException If any transport errors occur. * @throws NotFoundException If the identity or profile are not found. */ @Override - public void validateIdentity(CredentialIdentity identity) throws IdentityValidationException, - IOException, NotFoundException { + public void validateIdentity(NamespaceMeta namespaceMeta, CredentialIdentity identity) + throws IdentityValidationException, IOException, NotFoundException { try { - validateAndProvisionIdentity(identity); + validateAndProvisionIdentity(namespaceMeta, identity); } catch (CredentialProvisioningException e) { throw new IdentityValidationException(e); } } - private ProvisionedCredential validateAndProvisionIdentity(CredentialIdentity identity) + private ProvisionedCredential validateAndProvisionIdentity(NamespaceMeta namespaceMeta, + CredentialIdentity identity) throws CredentialProvisioningException, IOException, NotFoundException { CredentialProfileId profileId = new CredentialProfileId(identity.getProfileNamespace(), identity.getProfileName()); - contextAccessEnforcer.enforce(profileId, StandardPermission.USE); + // contextAccessEnforcer.enforce(profileId, StandardPermission.USE); Optional optProfile = credentialProfileManager.get(profileId); if (!optProfile.isPresent()) { throw new NotFoundException(String.format("Credential profile '%s' was not found.", @@ -131,6 +149,6 @@ private ProvisionedCredential validateAndProvisionIdentity(CredentialIdentity id + "'%'", providerType)); } // Provision and return the credential. - return credentialProviders.get(providerType).provision(profile, identity); + return credentialProviders.get(providerType).provision(namespaceMeta, profile, identity); } } diff --git a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/credential/handler/CredentialProviderHttpHandler.java b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/credential/handler/CredentialProviderHttpHandler.java index f254110d03a2..a4341aa772e2 100644 --- a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/credential/handler/CredentialProviderHttpHandler.java +++ b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/credential/handler/CredentialProviderHttpHandler.java @@ -29,6 +29,7 @@ import io.cdap.cdap.common.namespace.NamespaceQueryAdmin; import io.cdap.cdap.internal.credential.CredentialIdentityManager; import io.cdap.cdap.internal.credential.CredentialProfileManager; +import io.cdap.cdap.proto.NamespaceMeta; import io.cdap.cdap.proto.credential.CreateCredentialIdentityRequest; import io.cdap.cdap.proto.credential.CreateCredentialProfileRequest; import io.cdap.cdap.proto.credential.CredentialIdentity; @@ -114,18 +115,28 @@ public void listProviders(HttpRequest request, HttpResponder responder) { * @throws IOException If transport errors occur. */ @POST - @Path("/credentials/identities/validate") - public void validateIdentity(FullHttpRequest request, HttpResponder responder) + @Path("/namespaces/{namespace-id}/credentials/identities/validate") + public void validateIdentity(FullHttpRequest request, HttpResponder responder, + @PathParam("namespace-id") String namespace) throws BadRequestException, NotFoundException, IOException { CredentialIdentity identity = deserializeRequestContent(request, CredentialIdentity.class); + NamespaceMeta namespaceMeta; try { - credentialProvider.validateIdentity(identity); + namespaceMeta = namespaceQueryAdmin.get(new NamespaceId(namespace)); + } catch (Exception e) { + throw new IOException(String.format("Failed to get namespace '%s' metadata", + namespace), e) ; + } + try { + credentialProvider.validateIdentity(namespaceMeta, identity); } catch (IdentityValidationException e) { throw new BadRequestException(String.format("Identity failed validation with error: %s", e.getMessage()), e); } catch (io.cdap.cdap.proto.credential.NotFoundException e) { throw new NotFoundException(e.getMessage()); } + responder.sendJson(HttpResponseStatus.OK, + String.format("Identity %s validated successfully", identity.getIdentity())); } /** diff --git a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/credential/handler/CredentialProviderHttpHandlerInternal.java b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/credential/handler/CredentialProviderHttpHandlerInternal.java index 61f842f07b24..d0840e0de815 100644 --- a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/credential/handler/CredentialProviderHttpHandlerInternal.java +++ b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/credential/handler/CredentialProviderHttpHandlerInternal.java @@ -21,8 +21,11 @@ import com.google.inject.Singleton; import io.cdap.cdap.common.NotFoundException; import io.cdap.cdap.common.conf.Constants; +import io.cdap.cdap.common.namespace.NamespaceQueryAdmin; +import io.cdap.cdap.proto.NamespaceMeta; import io.cdap.cdap.proto.credential.CredentialProvider; import io.cdap.cdap.proto.credential.CredentialProvisioningException; +import io.cdap.cdap.proto.id.NamespaceId; import io.cdap.http.AbstractHttpHandler; import io.cdap.http.HttpHandler; import io.cdap.http.HttpResponder; @@ -46,10 +49,13 @@ public class CredentialProviderHttpHandlerInternal extends AbstractHttpHandler { new InstantEpochSecondsTypeAdapter()).create(); private final CredentialProvider credentialProvider; + private final NamespaceQueryAdmin namespaceQueryAdmin; @Inject - CredentialProviderHttpHandlerInternal(CredentialProvider credentialProvider) { + CredentialProviderHttpHandlerInternal(CredentialProvider credentialProvider, + NamespaceQueryAdmin namespaceQueryAdmin) { this.credentialProvider = credentialProvider; + this.namespaceQueryAdmin = namespaceQueryAdmin; } /** @@ -68,9 +74,16 @@ public class CredentialProviderHttpHandlerInternal extends AbstractHttpHandler { public void provisionCredential(HttpRequest request, HttpResponder responder, @PathParam("namespace-id") String namespace, @PathParam("identity-name") String identityName) throws CredentialProvisioningException, IOException, NotFoundException { + NamespaceMeta namespaceMeta; + try { + namespaceMeta = namespaceQueryAdmin.get(new NamespaceId(namespace)); + } catch (Exception e) { + throw new IOException(String.format("Failed to get namespace '%s' metadata", + namespace), e) ; + } try { responder.sendJson(HttpResponseStatus.OK, - GSON.toJson(credentialProvider.provision(namespace, identityName))); + GSON.toJson(credentialProvider.provision(namespaceMeta, identityName))); } catch (io.cdap.cdap.proto.credential.NotFoundException e) { throw new NotFoundException(e.getMessage()); } diff --git a/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/credential/CredentialProviderTestBase.java b/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/credential/CredentialProviderTestBase.java index fba2625df286..7558705600a5 100644 --- a/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/credential/CredentialProviderTestBase.java +++ b/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/credential/CredentialProviderTestBase.java @@ -101,14 +101,14 @@ protected void configure() { .getInstance(StructuredTableAdmin.class)); // Setup mock credential providers. CredentialProvider mockCredentialProvider = mock(CredentialProvider.class); - when(mockCredentialProvider.provision(any(), any())).thenReturn(RETURNED_TOKEN); + when(mockCredentialProvider.provision(any(), any(), any())).thenReturn(RETURNED_TOKEN); CredentialProvider validationFailureMockCredentialProvider = mock(CredentialProvider.class); - when(validationFailureMockCredentialProvider.provision(any(), any())) + when(validationFailureMockCredentialProvider.provision(any(), any(), any())) .thenReturn(RETURNED_TOKEN); doThrow(new ProfileValidationException("profile validation always fails with this provider")) .when(validationFailureMockCredentialProvider).validateProfile(any()); CredentialProvider provisionFailureMockCredentialProvider = mock(CredentialProvider.class); - when(provisionFailureMockCredentialProvider.provision(any(), any())) + when(provisionFailureMockCredentialProvider.provision(any(), any(), any())) .thenThrow(new CredentialProvisioningException("provisioning always fails with this " + "provider")); credentialProviders.put(CREDENTIAL_PROVIDER_TYPE_SUCCESS, mockCredentialProvider); diff --git a/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/credential/DefaultCredentialProviderServiceTest.java b/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/credential/DefaultCredentialProviderServiceTest.java index f4c2d6487450..1b096b570ae6 100644 --- a/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/credential/DefaultCredentialProviderServiceTest.java +++ b/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/credential/DefaultCredentialProviderServiceTest.java @@ -17,6 +17,7 @@ package io.cdap.cdap.internal.credential; import io.cdap.cdap.common.conf.CConfiguration; +import io.cdap.cdap.proto.NamespaceMeta; import io.cdap.cdap.proto.credential.CredentialIdentity; import io.cdap.cdap.proto.credential.CredentialProvisioningException; import io.cdap.cdap.proto.credential.IdentityValidationException; @@ -45,6 +46,12 @@ contextAccessEnforcer, new MockCredentialProviderLoader(), credentialIdentityMan public void testProvisionSuccess() throws Exception { // Create a new profile. String namespace = "testProvisionSuccess"; + String identityName = "test"; + NamespaceMeta namespaceMeta = new + NamespaceMeta.Builder() + .setName(namespace) + .setIdentity(identityName) + .buildWithoutKeytabUriVersion(); CredentialProfileId profileId = createDummyProfile(CREDENTIAL_PROVIDER_TYPE_SUCCESS, namespace, "test-profile"); @@ -54,13 +61,20 @@ public void testProvisionSuccess() throws Exception { profileId.getName(), "some-identity", "some-secure-value"); credentialIdentityManager.create(id, identity); - Assert.assertEquals(RETURNED_TOKEN, credentialProviderService.provision(namespace, "test")); + Assert.assertEquals(RETURNED_TOKEN, + credentialProviderService.provision(namespaceMeta, identityName)); } @Test(expected = NotFoundException.class) public void testProvisionWithNotFoundIdentityThrowsException() throws Exception { String namespace = "testProvisionWithNotFoundIdentityThrowsException"; - credentialProviderService.provision(namespace, "does-not-exist"); + String identityName = "does-not-exist"; + NamespaceMeta namespaceMeta = new + NamespaceMeta.Builder() + .setName(namespace) + .setIdentity(identityName) + .buildWithoutKeytabUriVersion(); + credentialProviderService.provision(namespaceMeta, identityName); } @Test(expected = CredentialProvisioningException.class) @@ -71,43 +85,68 @@ public void testProvisionFailureThrowsException() throws Exception { namespace, "test-profile"); // Create a new identity. - CredentialIdentityId id = new CredentialIdentityId(namespace, "test"); + String identityName = "test"; + CredentialIdentityId id = new CredentialIdentityId(namespace, identityName); CredentialIdentity identity = new CredentialIdentity(profileId.getNamespace(), profileId.getName(), "some-identity", "some-secure-value"); credentialIdentityManager.create(id, identity); - Assert.assertEquals(RETURNED_TOKEN, credentialProviderService.provision(namespace, "test")); + NamespaceMeta namespaceMeta = new + NamespaceMeta.Builder() + .setName(namespace) + .setIdentity(identityName) + .buildWithoutKeytabUriVersion(); + Assert.assertEquals(RETURNED_TOKEN, credentialProviderService.provision(namespaceMeta, + identityName)); } @Test public void testIdentityValidationSuccess() throws Exception { // Create a new profile. + String identityName = "some-identity"; String namespace = "testIdentityValidationSuccess"; + NamespaceMeta namespaceMeta = new + NamespaceMeta.Builder() + .setName(namespace) + .setIdentity(identityName) + .buildWithoutKeytabUriVersion(); CredentialProfileId profileId = createDummyProfile(CREDENTIAL_PROVIDER_TYPE_SUCCESS, namespace, "test-profile"); CredentialIdentity identity = new CredentialIdentity(profileId.getNamespace(), - profileId.getName(), "some-identity", "some-secure-value"); - credentialProviderService.validateIdentity(identity); + profileId.getName(), identityName, "some-secure-value"); + credentialProviderService.validateIdentity(namespaceMeta, identity); } @Test(expected = IdentityValidationException.class) public void testIdentityValidationOnProvisionFailureThrowsException() throws Exception { // Create a new profile. String namespace = "testIdentityValidationFailureThrowsException"; + String identityName = "some-identity"; + NamespaceMeta namespaceMeta = new + NamespaceMeta.Builder() + .setName(namespace) + .setIdentity(identityName) + .buildWithoutKeytabUriVersion(); CredentialProfileId profileId = createDummyProfile(CREDENTIAL_PROVIDER_TYPE_PROVISION_FAILURE, namespace, "test-profile"); CredentialIdentity identity = new CredentialIdentity(profileId.getNamespace(), - profileId.getName(), "some-identity", "some-secure-value"); - credentialProviderService.validateIdentity(identity); + profileId.getName(), identityName, "some-secure-value"); + credentialProviderService.validateIdentity(namespaceMeta, identity); } @Test(expected = NotFoundException.class) public void testIdentityValidationWithNotFoundProfileThrowsException() throws Exception { String namespace = "testIdentityValidationWithNotFoundProfileThrowsException"; + String identityName = "some-identity"; + NamespaceMeta namespaceMeta = new + NamespaceMeta.Builder() + .setName(namespace) + .setIdentity(identityName) + .buildWithoutKeytabUriVersion(); CredentialIdentity identity = new CredentialIdentity(namespace, "does-not-exist", - "some-identity", "some-secure-value"); - credentialProviderService.validateIdentity(identity); + identityName, "some-secure-value"); + credentialProviderService.validateIdentity(namespaceMeta, identity); } } diff --git a/cdap-common/src/main/resources/cdap-default.xml b/cdap-common/src/main/resources/cdap-default.xml index dc01da3e1724..af0897abe344 100644 --- a/cdap-common/src/main/resources/cdap-default.xml +++ b/cdap-common/src/main/resources/cdap-default.xml @@ -5888,7 +5888,7 @@ feature.namespaced.service.accounts.enabled - false + true If true, namespace service accounts feature will be enabled. diff --git a/cdap-credential-ext-gcp-wi/pom.xml b/cdap-credential-ext-gcp-wi/pom.xml new file mode 100644 index 000000000000..1dcccdfa7794 --- /dev/null +++ b/cdap-credential-ext-gcp-wi/pom.xml @@ -0,0 +1,118 @@ + + + + + + cdap + io.cdap.cdap + 6.10.0-SNAPSHOT + + 4.0.0 + + cdap-credential-ext-gcp-wi + CDAP Google Cloud Platform Credential Provider Extension + jar + + + 31.1-jre + 16.0.2 + + + + + io.cdap.cdap + cdap-security-spi + ${project.version} + provided + + + com.google.guava + guava + ${guava.version} + + + io.kubernetes + client-java + ${k8s.version} + + + + + junit + junit + test + + + org.mockito + mockito-core + test + + + + + + dist + + + + org.apache.maven.plugins + maven-dependency-plugin + 2.8 + + + copy-dependencies + prepare-package + + copy-dependencies + + + ${project.build.directory}/libexec + false + false + true + true + true + runtime + + + + + + org.apache.maven.plugins + maven-jar-plugin + 2.4 + + + jar + prepare-package + + ${project.build.directory}/libexec + ${project.groupId}.${project.build.finalName} + + + jar + + + + + + + + + diff --git a/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/GcpWorkloadIdentityCredentialProvider.java b/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/GcpWorkloadIdentityCredentialProvider.java new file mode 100644 index 000000000000..5c8cfaf2aa0f --- /dev/null +++ b/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/GcpWorkloadIdentityCredentialProvider.java @@ -0,0 +1,233 @@ +/* + * Copyright © 2023 Cask Data, Inc. + * + * Licensed 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 io.cdap.cdap.security.spi.credential; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.cdap.cdap.proto.BasicThrowable; +import io.cdap.cdap.proto.NamespaceMeta; +import io.cdap.cdap.proto.codec.BasicThrowableCodec; +import io.cdap.cdap.proto.credential.CredentialIdentity; +import io.cdap.cdap.proto.credential.CredentialProfile; +import io.cdap.cdap.proto.credential.CredentialProvisioningException; +import io.cdap.cdap.proto.credential.ProvisionedCredential; +import io.cdap.cdap.proto.id.NamespaceId; +import io.cdap.cdap.security.spi.credential.SecurityTokenServiceRequest.GrantType; +import io.cdap.cdap.security.spi.credential.SecurityTokenServiceRequest.TokenType; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.AuthenticationV1TokenRequest; +import io.kubernetes.client.openapi.models.V1TokenRequestSpec; +import io.kubernetes.client.util.Config; +import java.io.BufferedReader; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import javax.ws.rs.HttpMethod; +import javax.ws.rs.core.HttpHeaders; +import okhttp3.OkHttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link CredentialProvider} Credential Provider which returns application default credentials. + * For more details, see + * medium.com/google-cloud/gcp-workload-identity-federation-with-federated-tokens-d03b8bad0228 + */ +public class GcpWorkloadIdentityCredentialProvider implements CredentialProvider { + public static final String NAME = "gcp-credential-provider"; + private static final Gson GSON = new GsonBuilder().registerTypeAdapter(BasicThrowable.class, + new BasicThrowableCodec()).create(); + private static final String TOKEN_EXCHANGE_AUDIENCE_FORMAT = "identitynamespace:%s:%s"; + private static final Logger LOG = + LoggerFactory.getLogger(GcpWorkloadIdentityCredentialProvider.class); + private CredentialProviderContext credentialProviderContext; + private static final String CONNECT_TIMEOUT = "k8s.connect.timeout.sec"; + static final String CONNECT_TIMEOUT_DEFAULT = "120"; + private static final String READ_TIMEOUT = "k8s.read.timeout.sec"; + static final String READ_TIMEOUT_DEFAULT = "300"; + private ApiClient client; + private static final String WORKLOAD_IDENTITY_POOL = "k8s.workload.identity.pool"; + private static final String WORKLOAD_IDENTITY_PROVIDER = "k8s.workload.identity.provider"; + static final String CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; + + @Override + public String getName() { + return NAME; + } + + @Override + public void initialize(CredentialProviderContext credentialProviderContext) throws IOException { + + LOG.info("Initialized GcpWorkload Identity Credential Provider"); + this.credentialProviderContext = credentialProviderContext; + + this.client = Config.defaultClient(); + int connectTimeoutSec = Integer.parseInt(credentialProviderContext.getProperties() + .getOrDefault(CONNECT_TIMEOUT, CONNECT_TIMEOUT_DEFAULT)); + int readTimeoutSec = Integer.parseInt(credentialProviderContext.getProperties() + .getOrDefault(READ_TIMEOUT, READ_TIMEOUT_DEFAULT)); + OkHttpClient httpClient = this.client.getHttpClient().newBuilder() + .connectTimeout(connectTimeoutSec, TimeUnit.SECONDS) + .readTimeout(readTimeoutSec, TimeUnit.SECONDS) + .build(); + this.client.setHttpClient(httpClient); + } + + @Override + public ProvisionedCredential provision(NamespaceMeta namespaceMeta, + CredentialProfile profile, CredentialIdentity identity) + throws CredentialProvisioningException { + + LOG.debug("Validate Identity {}", identity.getIdentity()); + try { + String workloadIdentityPool = + credentialProviderContext.getProperties().get(WORKLOAD_IDENTITY_POOL); + + // generate k8s SA token for pod + String k8sSaToken = getK8sServiceAccountToken(NamespaceId.DEFAULT.getNamespace(), + identity.getIdentity(), workloadIdentityPool); + LOG.trace("Successfully generated K8SA token."); + + String workloadIdentityProvider = + credentialProviderContext.getProperties().get(WORKLOAD_IDENTITY_PROVIDER); + + // exchange JWT token via STS for Federating Token + String tokenExchangeAudience = String.format(TOKEN_EXCHANGE_AUDIENCE_FORMAT, + workloadIdentityPool, workloadIdentityProvider); + + SecurityTokenServiceResponse securityTokenServiceResponse = GSON.fromJson( + exchangeTokenViaSts(k8sSaToken, CLOUD_PLATFORM_SCOPE, tokenExchangeAudience), + SecurityTokenServiceResponse.class + ); + LOG.trace("Successfully exchanged JWT token via STS for Federating Token via STS."); + + // get GSA token using Federating Token as credential + IamCredentialGenerateAccessTokenResponse iamCredentialGenerateAccessTokenResponse = + GSON.fromJson(getIamServiceAccountToken(securityTokenServiceResponse.getAccessToken(), + CLOUD_PLATFORM_SCOPE, identity.getSecureValue()), + IamCredentialGenerateAccessTokenResponse.class); + LOG.trace("Successfully generated GSA token using Federating Token as credential."); + + return new ProvisionedCredential(iamCredentialGenerateAccessTokenResponse.getAccessToken(), + Instant.parse(iamCredentialGenerateAccessTokenResponse.getExpireTime())); + + } catch (Exception e) { + LOG.info("Failed to provision credential with identity '{}'.", identity.getIdentity(), e); + throw new CredentialProvisioningException( + String.format("Failed to provision credential with identity %s", identity.getIdentity()), + e); + } + } + + @Override + public void validateProfile(CredentialProfile profile) throws ProfileValidationException { + if (!profile.getCredentialProviderType().equals(NAME)) { + throw new ProfileValidationException( + String.format("Profile is not supported by %s credential provider", NAME)); + } + } + + private String getK8sServiceAccountToken(String namespace, + String serviceAccountName, String audience) throws ApiException { + CoreV1Api coreV1Api = new CoreV1Api(client); + + // Create the TokenRequestSpec with the specified audience + V1TokenRequestSpec v1TokenRequestSpec = new V1TokenRequestSpec() + .audiences(Collections.singletonList(audience)) + .expirationSeconds(3600L); + + AuthenticationV1TokenRequest authenticationV1TokenRequest = new AuthenticationV1TokenRequest() + .apiVersion("authentication.k8s.io/v1") + .kind("TokenRequest") + .spec(v1TokenRequestSpec); + + authenticationV1TokenRequest = coreV1Api.createNamespacedServiceAccountToken(namespace, + serviceAccountName, authenticationV1TokenRequest, null, null, + null, null); + + return authenticationV1TokenRequest.getStatus().getToken(); + } + + private String exchangeTokenViaSts(String token, String scopes, String audience) + throws IOException { + + SecurityTokenServiceRequest securityTokenServiceRequest = + new SecurityTokenServiceRequest(GrantType.TOKEN_EXCHANCE, audience, scopes, + TokenType.ACCESS_TOKEN, TokenType.JWT, token); + String securityTokenServiceRequestJson = GSON.toJson(securityTokenServiceRequest); + URL url = new URL(SecurityTokenServiceRequest.STS_ENDPOINT); + + Map headers = new HashMap<>(); + headers.put(HttpHeaders.CONTENT_TYPE, "application/json"); + + LOG.info("STS token request {}", securityTokenServiceRequestJson); + return executeHttpPostRequest(url, securityTokenServiceRequestJson, headers); + } + + private String executeHttpPostRequest(URL url, String body, Map headers) + throws IOException { + + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod(HttpMethod.POST); + connection.setUseCaches(false); + for (Map.Entry entry : headers.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + } + connection.setDoOutput(true); + + // Write the request body to the output stream + try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) { + outputStream.writeBytes(body); + outputStream.flush(); + } + + StringBuilder response = new StringBuilder(); + try (BufferedReader in = new BufferedReader( + new InputStreamReader(connection.getInputStream()))) { + String inputLine; + while ((inputLine = in.readLine()) != null) { + response.append(inputLine); + } + } + return response.toString(); + } + + private String getIamServiceAccountToken(String token, String scopes, + String serviceAccountEmail) throws IOException { + + URL url = new URL(String.format( + IamCredentialsGenerateAccessTokenRequest.IAM_CREDENTIALS_GENERATE_SA_TOKEN_URL_FORMAT, + serviceAccountEmail)); + IamCredentialsGenerateAccessTokenRequest credentialsGenerateAccessTokenRequest = new + IamCredentialsGenerateAccessTokenRequest(scopes); + + Map headers = new HashMap<>(); + headers.put(HttpHeaders.AUTHORIZATION, String.format("Bearer %s", token)); + headers.put(HttpHeaders.CONTENT_TYPE, "application/json"); + String generateAccessTokenRequestJson = GSON.toJson(credentialsGenerateAccessTokenRequest); + return executeHttpPostRequest(url, generateAccessTokenRequestJson, headers); + } +} diff --git a/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/IamCredentialGenerateAccessTokenResponse.java b/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/IamCredentialGenerateAccessTokenResponse.java new file mode 100644 index 000000000000..75eede547600 --- /dev/null +++ b/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/IamCredentialGenerateAccessTokenResponse.java @@ -0,0 +1,51 @@ +/* + * Copyright © 2023 Cask Data, Inc. + * + * Licensed 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 io.cdap.cdap.security.spi.credential; + +import com.google.gson.annotations.SerializedName; + +/** + * Represents a IAM Credentials General Access Token request. For more details, + * + * see + * + */ +public class IamCredentialGenerateAccessTokenResponse { + + @SerializedName("accessToken") + private final String accessToken; + + @SerializedName("expireTime") + private final String expireTime; + + public String getAccessToken() { + return accessToken; + } + + public String getExpireTime() { + return expireTime; + } + + /** + * Constructs a {@link IamCredentialGenerateAccessTokenResponse}. + */ + public IamCredentialGenerateAccessTokenResponse(String accessToken, String expireTime) { + this.accessToken = accessToken; + this.expireTime = expireTime; + } +} diff --git a/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/IamCredentialsGenerateAccessTokenRequest.java b/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/IamCredentialsGenerateAccessTokenRequest.java new file mode 100644 index 000000000000..cc57492ed098 --- /dev/null +++ b/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/IamCredentialsGenerateAccessTokenRequest.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2023 Cask Data, Inc. + * + * Licensed 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 io.cdap.cdap.security.spi.credential; + +import com.google.gson.annotations.SerializedName; + +/** + * Represents a IAM Credentials General Access Token request. For more details, + * + * see + */ +public class IamCredentialsGenerateAccessTokenRequest { + + public static final String IAM_CREDENTIALS_GENERATE_SA_TOKEN_URL_FORMAT = + "https://iamcredentials.googleapis.com/" + + "v1/projects/-/serviceAccounts/%s:generateAccessToken"; + + @SerializedName("scope") + private final String scope; + + /** + * Constructs a {@link IamCredentialsGenerateAccessTokenRequest}. + */ + public IamCredentialsGenerateAccessTokenRequest(String scope) { + this.scope = scope; + } +} diff --git a/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/SecurityTokenServiceRequest.java b/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/SecurityTokenServiceRequest.java new file mode 100644 index 000000000000..ae753e5b06c5 --- /dev/null +++ b/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/SecurityTokenServiceRequest.java @@ -0,0 +1,75 @@ +/* + * Copyright © 2023 Cask Data, Inc. + * + * Licensed 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 io.cdap.cdap.security.spi.credential; + +import com.google.gson.annotations.SerializedName; + +/** + * Represents a Security Token Service Token Exchange request. For more details, + * see + */ +public class SecurityTokenServiceRequest { + + public static String STS_ENDPOINT = "https://sts.googleapis.com/v1/token"; + + /** + * Represents the type of token. + */ + public enum TokenType { + @SerializedName("urn:ietf:params:oauth:token-type:jwt") + JWT, + @SerializedName("urn:ietf:params:oauth:token-type:access_token") + ACCESS_TOKEN + } + + public enum GrantType { + @SerializedName("urn:ietf:params:oauth:grant-type:token-exchange") + TOKEN_EXCHANCE + } + + @SerializedName("grantType") + private final GrantType grantType; + + @SerializedName("audience") + private final String audience; + + @SerializedName("scope") + private final String scope; + + @SerializedName("subjectTokenType") + private final TokenType subjectTokenType; + + @SerializedName("requestedTokenType") + private final TokenType requestedTokenType; + + @SerializedName("subjectToken") + private final String subjectToken; + + /** + * Constructs a {@link SecurityTokenServiceRequest}. + */ + public SecurityTokenServiceRequest(GrantType grantType, String audience, String scope, + TokenType requestedTokenType, TokenType subjectTokenType, String subjectToken) { + this.grantType = grantType; + this.requestedTokenType = requestedTokenType; + this.subjectTokenType = subjectTokenType; + this.audience = audience; + this.scope = scope; + this.subjectToken = subjectToken; + } + +} diff --git a/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/SecurityTokenServiceResponse.java b/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/SecurityTokenServiceResponse.java new file mode 100644 index 000000000000..fa30a74795ab --- /dev/null +++ b/cdap-credential-ext-gcp-wi/src/main/java/io/cdap/cdap/security/spi/credential/SecurityTokenServiceResponse.java @@ -0,0 +1,56 @@ +/* + * Copyright © 2023 Cask Data, Inc. + * + * Licensed 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 io.cdap.cdap.security.spi.credential; + +import com.google.gson.annotations.SerializedName; +import io.cdap.cdap.security.spi.credential.SecurityTokenServiceRequest.TokenType; + +/** + * Represents a Security Token Service Token Exchange response. For more details, + * + * see + * + */ +public class SecurityTokenServiceResponse { + + @SerializedName("access_token") + private final String accessToken; + + @SerializedName("issued_token_type") + private final TokenType issuedTokenType; + + @SerializedName("token_type") + private final String tokenType; + + @SerializedName("expires_in") + private final int expiresIn; + + public String getAccessToken() { + return accessToken; + } + + /** + * Constructs a {@link SecurityTokenServiceResponse}. + */ + public SecurityTokenServiceResponse(String accessToken, TokenType issuedTokenType, + String tokenType, int expiresIn) { + this.accessToken = accessToken; + this.issuedTokenType = issuedTokenType; + this.tokenType = tokenType; + this.expiresIn = expiresIn; + } +} diff --git a/cdap-credential-ext-gcp-wi/src/main/resources/META-INF/services/io.cdap.cdap.security.spi.credential.CredentialProvider b/cdap-credential-ext-gcp-wi/src/main/resources/META-INF/services/io.cdap.cdap.security.spi.credential.CredentialProvider new file mode 100644 index 000000000000..8d078a7def33 --- /dev/null +++ b/cdap-credential-ext-gcp-wi/src/main/resources/META-INF/services/io.cdap.cdap.security.spi.credential.CredentialProvider @@ -0,0 +1,17 @@ +# +# Copyright © 2023 Cask Data, Inc. +# +# Licensed 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. +# + +io.cdap.cdap.security.spi.credential.GcpWorkloadIdentityCredentialProvider diff --git a/cdap-master/pom.xml b/cdap-master/pom.xml index 6592d61462f0..fa9dbd7a96a6 100644 --- a/cdap-master/pom.xml +++ b/cdap-master/pom.xml @@ -274,6 +274,7 @@ ${stage.opt.dir}/ext/securestores ${stage.opt.dir}/ext/storageproviders ${stage.opt.dir}/ext/authenticators + ${stage.opt.dir}/ext/credentialproviders ${stage.opt.dir}/ext/metricswriters/google_cloud_monitoring_writer ${stage.opt.dir}/ext/eventwriters/google_cloud_pubsub_writer @@ -743,6 +744,27 @@ + + + copy-credential-ext-gcp-wi + process-resources + + copy-resources + + + ${stage.credential.provider.extensions.dir}/gcp-credential-provider + + + + ${project.parent.basedir}/cdap-credential-ext-gcp-wi/target/libexec/ + + + *.jar + + + + + diff --git a/cdap-proto/src/main/java/io/cdap/cdap/proto/credential/CredentialProvider.java b/cdap-proto/src/main/java/io/cdap/cdap/proto/credential/CredentialProvider.java index cc7d79e32e4e..fe6c5ebb07f3 100644 --- a/cdap-proto/src/main/java/io/cdap/cdap/proto/credential/CredentialProvider.java +++ b/cdap-proto/src/main/java/io/cdap/cdap/proto/credential/CredentialProvider.java @@ -16,6 +16,7 @@ package io.cdap.cdap.proto.credential; +import io.cdap.cdap.proto.NamespaceMeta; import java.io.IOException; /** @@ -26,24 +27,25 @@ public interface CredentialProvider { /** * Provisions a short-lived credential for the provided identity using the provided identity. * - * @param namespace The identity namespace. + * @param namespaceMeta The identity namespace metadata. * @param identityName The identity name. * @return A short-lived credential. * @throws CredentialProvisioningException If provisioning the credential fails. * @throws IOException If any transport errors occur. * @throws NotFoundException If the profile or identity are not found. */ - ProvisionedCredential provision(String namespace, String identityName) + ProvisionedCredential provision(NamespaceMeta namespaceMeta, String identityName) throws CredentialProvisioningException, IOException, NotFoundException; /** * Validates the provided identity. * + * @param namespaceMeta The identity namespace metadata. * @param identity The identity to validate. * @throws IdentityValidationException If validation fails. * @throws IOException If any transport errors occur. * @throws NotFoundException If the profile is not found. */ - void validateIdentity(CredentialIdentity identity) throws IdentityValidationException, - IOException, NotFoundException; + void validateIdentity(NamespaceMeta namespaceMeta, CredentialIdentity identity) + throws IdentityValidationException, IOException, NotFoundException; } diff --git a/cdap-proto/src/main/java/io/cdap/cdap/proto/credential/CredentialProvisioningException.java b/cdap-proto/src/main/java/io/cdap/cdap/proto/credential/CredentialProvisioningException.java index 47aecf74425d..f36d3a52166f 100644 --- a/cdap-proto/src/main/java/io/cdap/cdap/proto/credential/CredentialProvisioningException.java +++ b/cdap-proto/src/main/java/io/cdap/cdap/proto/credential/CredentialProvisioningException.java @@ -29,4 +29,14 @@ public class CredentialProvisioningException extends Exception { public CredentialProvisioningException(String message) { super(message); } + + /** + * Creates a new credential provisioning exception. + * + * @param message The message for the provisioning failure. + * @param cause cause of the failure. + */ + public CredentialProvisioningException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/cdap-proto/src/main/java/io/cdap/cdap/proto/id/EntityId.java b/cdap-proto/src/main/java/io/cdap/cdap/proto/id/EntityId.java index 80b34dd1625c..82bf816a95b7 100644 --- a/cdap-proto/src/main/java/io/cdap/cdap/proto/id/EntityId.java +++ b/cdap-proto/src/main/java/io/cdap/cdap/proto/id/EntityId.java @@ -199,7 +199,7 @@ public static boolean isValidCredentialId(String credentialId) { public static void ensureValidCredentialId(String name) { if (!isValidCredentialId(name)) { throw new IllegalArgumentException( - String.format("Invalid credential ID: %s. Should only contain alphanumeric " + String.format("Invalid credential ID: %s. Should only contain lowercase alphanumeric " + "characters and _ or -.", name)); } } diff --git a/cdap-security-spi/src/main/java/io/cdap/cdap/security/spi/credential/CredentialProvider.java b/cdap-security-spi/src/main/java/io/cdap/cdap/security/spi/credential/CredentialProvider.java index e519f633161c..774b12f64fa0 100644 --- a/cdap-security-spi/src/main/java/io/cdap/cdap/security/spi/credential/CredentialProvider.java +++ b/cdap-security-spi/src/main/java/io/cdap/cdap/security/spi/credential/CredentialProvider.java @@ -16,6 +16,7 @@ package io.cdap.cdap.security.spi.credential; +import io.cdap.cdap.proto.NamespaceMeta; import io.cdap.cdap.proto.credential.CredentialIdentity; import io.cdap.cdap.proto.credential.CredentialProfile; import io.cdap.cdap.proto.credential.CredentialProvisioningException; @@ -38,19 +39,22 @@ public interface CredentialProvider { * methods (except for {@link CredentialProvider#getName()} are called. * * @param context The credential provider context to initialize with. + * @throws Exception if initialization fails. */ - void initialize(CredentialProviderContext context); + void initialize(CredentialProviderContext context) throws Exception; /** * Provisions a short-lived credential for the provided identity using the provided credential * profile. * + * @param namespaceMeta The credential identity namespace metadata. * @param profile The credential profile to use. * @param identity The credential identity to use. * @return A credential provisioned using the specified profile and identity. * @throws CredentialProvisioningException If the credential provisioning fails. */ - ProvisionedCredential provision(CredentialProfile profile, CredentialIdentity identity) + ProvisionedCredential provision(NamespaceMeta namespaceMeta, CredentialProfile profile, + CredentialIdentity identity) throws CredentialProvisioningException; /** diff --git a/pom.xml b/pom.xml index 1e7583a725b6..06dcd4195397 100644 --- a/pom.xml +++ b/pom.xml @@ -2750,6 +2750,7 @@ cdap-master-spi cdap-master cdap-proto + cdap-credential-ext-gcp-wi cdap-runtime-ext-dataproc cdap-runtime-ext-emr cdap-runtime-ext-remote-hadoop