Support Google Cloud Source repositories (#1278)

* Extract transport configuration to TransportConfigCallbackFactory

* Authenticate against Google Cloud Source repositories

Fixes gh-1260
This commit is contained in:
Eduard Wirch
2019-04-11 21:25:22 +02:00
committed by Ryan Baxter
parent 3c0348ca62
commit 79bea06f3e
10 changed files with 482 additions and 22 deletions

View File

@@ -334,6 +334,17 @@ AWS EC2 instances may use https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ia
NOTE: The `aws-java-sdk-core` jar is an optional dependency.
If the `aws-java-sdk-core` jar is not on your classpath, the AWS Code Commit credential provider is not created, regardless of the git server URI.
===== Authentication with Google Cloud Source
Spring Cloud Config Server also supports authenticating against https://cloud.google.com/source-repositories/[Google Cloud Source] repositories.
If your Git URI uses the `http` or `https` protocol and the domain name is `source.developers.google.com`, the Google Cloud Source credentials provider will be used. A Google Cloud Source repository URI has the format `https://source.developers.google.com/p/${GCP_PROJECT}/r/${REPO}`. To obtain the URI for your repository, click on "Clone" in the Google Cloud Source UI, and select "Manually generated credentials". Do not generate any credentials, simply copy the displayed URI.
The Google Cloud Source credentials provider will use Google Cloud Platform application default credentials. See https://cloud.google.com/sdk/gcloud/reference/auth/application-default/login[Google Cloud SDK documentation] on how to create application default credentials for a system. This approach will work for user accounts in dev environments and for service accounts in production environments.
NOTE: `com.google.auth:google-auth-library-oauth2-http` is an optional dependency.
If the `google-auth-library-oauth2-http` jar is not on your classpath, the Google Cloud Source credential provider is not created, regardless of the git server URI.
===== Git SSH configuration using properties
By default, the JGit library used by Spring Cloud Config Server uses SSH configuration files such as `~/.ssh/known_hosts` and `/etc/ssh/ssh_config` when connecting to Git repositories by using an SSH URI.

View File

@@ -70,6 +70,11 @@
<artifactId>aws-java-sdk-core</artifactId>
<version>1.11.52</version>
</dependency>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
<version>0.15.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<profiles>

View File

@@ -85,6 +85,11 @@
<artifactId>spring-boot-autoconfigure-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>

View File

@@ -64,6 +64,8 @@ import org.springframework.cloud.config.server.environment.SvnKitEnvironmentRepo
import org.springframework.cloud.config.server.environment.VaultEnvironmentProperties;
import org.springframework.cloud.config.server.environment.VaultEnvironmentRepository;
import org.springframework.cloud.config.server.environment.VaultEnvironmentRepositoryFactory;
import org.springframework.cloud.config.server.support.GoogleCloudSourceSupport;
import org.springframework.cloud.config.server.support.TransportConfigCallbackFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@@ -91,7 +93,8 @@ import org.springframework.jdbc.core.JdbcTemplate;
VaultRepositoryConfiguration.class, CredhubConfiguration.class,
CredhubRepositoryConfiguration.class, SvnRepositoryConfiguration.class,
NativeRepositoryConfiguration.class, GitRepositoryConfiguration.class,
RedisRepositoryConfiguration.class, DefaultRepositoryConfiguration.class })
RedisRepositoryConfiguration.class, GoogleCloudSourceConfiguration.class,
DefaultRepositoryConfiguration.class })
public class EnvironmentRepositoryConfiguration {
@Bean
@@ -137,9 +140,13 @@ public class EnvironmentRepositoryConfiguration {
public MultipleJGitEnvironmentRepositoryFactory gitEnvironmentRepositoryFactory(
ConfigurableEnvironment environment, ConfigServerProperties server,
Optional<ConfigurableHttpConnectionFactory> jgitHttpConnectionFactory,
Optional<TransportConfigCallback> customTransportConfigCallback) {
Optional<TransportConfigCallback> customTransportConfigCallback,
Optional<GoogleCloudSourceSupport> googleCloudSourceSupport) {
final TransportConfigCallbackFactory transportConfigCallbackFactory = new TransportConfigCallbackFactory(
customTransportConfigCallback.orElse(null),
googleCloudSourceSupport.orElse(null));
return new MultipleJGitEnvironmentRepositoryFactory(environment, server,
jgitHttpConnectionFactory, customTransportConfigCallback);
jgitHttpConnectionFactory, transportConfigCallbackFactory);
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2013-2019 the original author or authors.
*
* 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
*
* https://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.springframework.cloud.config.server.config;
import com.google.auth.oauth2.GoogleCredentials;
import org.eclipse.jgit.api.TransportConfigCallback;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.cloud.config.server.support.GoogleCloudSourceSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Adds Google Cloud Source OAuth2 support, if
* com.google.auth:google-auth-library-oauth2-http library is on classpath.
*
* @author Eduard Wirch
*/
@Configuration
@ConditionalOnClass({ GoogleCredentials.class, TransportConfigCallback.class })
public class GoogleCloudSourceConfiguration {
@Bean
public GoogleCloudSourceSupport createGoogleCloudSourceSupport() {
return new GoogleCloudSourceSupport();
}
}

View File

@@ -18,12 +18,10 @@ package org.springframework.cloud.config.server.environment;
import java.util.Optional;
import org.eclipse.jgit.api.TransportConfigCallback;
import org.eclipse.jgit.transport.HttpTransport;
import org.springframework.cloud.config.server.config.ConfigServerProperties;
import org.springframework.cloud.config.server.ssh.FileBasedSshTransportConfigCallback;
import org.springframework.cloud.config.server.ssh.PropertiesBasedSshTransportConfigCallback;
import org.springframework.cloud.config.server.support.TransportConfigCallbackFactory;
import org.springframework.core.env.ConfigurableEnvironment;
/**
@@ -38,23 +36,23 @@ public class MultipleJGitEnvironmentRepositoryFactory implements
private Optional<ConfigurableHttpConnectionFactory> connectionFactory;
private Optional<TransportConfigCallback> customTransportConfigCallback;
private final TransportConfigCallbackFactory transportConfigCallbackFactory;
@Deprecated
public MultipleJGitEnvironmentRepositoryFactory(ConfigurableEnvironment environment,
ConfigServerProperties server,
Optional<TransportConfigCallback> customTransportConfigCallback) {
this(environment, server, Optional.empty(), customTransportConfigCallback);
TransportConfigCallbackFactory transportConfigCallbackFactory) {
this(environment, server, Optional.empty(), transportConfigCallbackFactory);
}
public MultipleJGitEnvironmentRepositoryFactory(ConfigurableEnvironment environment,
ConfigServerProperties server,
Optional<ConfigurableHttpConnectionFactory> connectionFactory,
Optional<TransportConfigCallback> customTransportConfigCallback) {
TransportConfigCallbackFactory transportConfigCallbackFactory) {
this.environment = environment;
this.server = server;
this.connectionFactory = connectionFactory;
this.customTransportConfigCallback = customTransportConfigCallback;
this.transportConfigCallbackFactory = transportConfigCallbackFactory;
}
@Override
@@ -67,21 +65,12 @@ public class MultipleJGitEnvironmentRepositoryFactory implements
MultipleJGitEnvironmentRepository repository = new MultipleJGitEnvironmentRepository(
this.environment, environmentProperties);
repository.setTransportConfigCallback(this.customTransportConfigCallback
.orElse(buildTransportConfigCallback(environmentProperties)));
repository.setTransportConfigCallback(
transportConfigCallbackFactory.build(environmentProperties));
if (this.server.getDefaultLabel() != null) {
repository.setDefaultLabel(this.server.getDefaultLabel());
}
return repository;
}
private TransportConfigCallback buildTransportConfigCallback(
MultipleJGitEnvironmentProperties gitEnvironmentProperties) {
if (gitEnvironmentProperties.isIgnoreLocalSshSettings()) {
return new PropertiesBasedSshTransportConfigCallback(
gitEnvironmentProperties);
}
return new FileBasedSshTransportConfigCallback(gitEnvironmentProperties);
}
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright 2013-2019 the original author or authors.
*
* 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
*
* https://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.springframework.cloud.config.server.support;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import com.google.auth.oauth2.GoogleCredentials;
import org.eclipse.jgit.api.TransportConfigCallback;
import org.eclipse.jgit.transport.Transport;
import org.eclipse.jgit.transport.TransportHttp;
import org.eclipse.jgit.transport.URIish;
import static java.util.stream.Collectors.toMap;
/**
* Provides credentials for Google Cloud Source repositories by adding a
* {@code Authenticate} http header.
* <p/>
* It does so by acting as a transport configurer. If a transport instance targets a
* Google Cloud Source repository, this implementation retrieves Google Cloud application
* default credentials and adds them as a http header.
*
* @author Eduard Wirch
* @see <a href=
* "https://cloud.google.com/sdk/gcloud/reference/auth/application-default/login"> gcloud
* auth application-default login</a>
*/
public final class GoogleCloudSourceSupport {
boolean canHandle(String uri) {
try {
return GCSTransportConfigCallback.canHandle(new URIish(uri));
}
catch (URISyntaxException e) {
return false;
}
}
// This detour via GCSTransportConfigCallback was necessary because:
// - we want the Google Cloud credentials provider to be a bean, so we can use
// @ConditionalOnClass to conditionally disable support, if required classes
// are not on the class path.
// - We cannot make a class implementing TransportConfigCallback a bean,
// because Spring would populate customTransportConfigCallback with this bean
// (see JGitFactoryConfig.gitEnvironmentRepositoryFactory()), and report a
// conflict whenever there is a real "custom" TransportConfigCallback
// implementation in the Spring context.
// This is why GoogleCloudSourceSupport is the optional bean, which can provide
// the TransportConfigCallback on request.
TransportConfigCallback createTransportConfigCallback() {
return new GCSTransportConfigCallback(
new ApplicationDefaultCredentialsProvider());
}
TransportConfigCallback createTransportConfigCallback(
CredentialsProvider credentialsProvider) {
return new GCSTransportConfigCallback(credentialsProvider);
}
private static final class GCSTransportConfigCallback
implements TransportConfigCallback {
private static final String GOOGLE_CLOUD_SOURCE_HOST = "source.developers.google.com";
private final CredentialsProvider credentialsProvider;
private GCSTransportConfigCallback(CredentialsProvider credentialsProvider) {
this.credentialsProvider = credentialsProvider;
}
@Override
public void configure(Transport transport) {
if (transport instanceof TransportHttp && canHandle(transport.getURI())) {
addHeaders((TransportHttp) transport,
credentialsProvider.getAuthorizationHeaders());
}
}
private static boolean canHandle(URIish uri) {
return isHttpScheme(uri) && isGoogleCloudSourceHost(uri);
}
private static boolean isHttpScheme(URIish uri) {
final String scheme = uri.getScheme();
return Objects.equals(scheme, "http") || Objects.equals(scheme, "https");
}
private static boolean isGoogleCloudSourceHost(URIish uri) {
return Objects.equals(uri.getHost(), GOOGLE_CLOUD_SOURCE_HOST);
}
private void addHeaders(TransportHttp transport, Map<String, String> headers) {
transport.setAdditionalHeaders(headers);
}
}
interface CredentialsProvider {
Map<String, String> getAuthorizationHeaders();
}
private static class ApplicationDefaultCredentialsProvider
implements CredentialsProvider {
@Override
public Map<String, String> getAuthorizationHeaders() {
try {
return GoogleCredentials.getApplicationDefault().getRequestMetadata()
.entrySet().stream()
.collect(toMap(Entry::getKey, this::joinValues));
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
private String joinValues(Entry<?, List<String>> entry) {
return String.join(", ", entry.getValue());
}
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2013-2019 the original author or authors.
*
* 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
*
* https://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.springframework.cloud.config.server.support;
import javax.annotation.Nullable;
import org.eclipse.jgit.api.TransportConfigCallback;
import org.springframework.cloud.config.server.environment.MultipleJGitEnvironmentProperties;
import org.springframework.cloud.config.server.ssh.FileBasedSshTransportConfigCallback;
import org.springframework.cloud.config.server.ssh.PropertiesBasedSshTransportConfigCallback;
public class TransportConfigCallbackFactory {
@Nullable
private final TransportConfigCallback customTransportConfigCallback;
@Nullable
private final GoogleCloudSourceSupport googleCloudSourceSupport;
public TransportConfigCallbackFactory(
TransportConfigCallback customTransportConfigCallback,
GoogleCloudSourceSupport googleCloudSourceSupport) {
this.customTransportConfigCallback = customTransportConfigCallback;
this.googleCloudSourceSupport = googleCloudSourceSupport;
}
public TransportConfigCallback build(
MultipleJGitEnvironmentProperties environmentProperties) {
// customTransportConfigCallback has the highest priority. If someone put
// a TransportConfigCallback bean in to the Spring context, we use it for
// all repositories.
if (customTransportConfigCallback != null) {
return customTransportConfigCallback;
}
// If the currently configured repository is a Google Cloud Source repository
// we use GoogleCloudSourceSupport.
if (googleCloudSourceSupport != null) {
final String uri = environmentProperties.getUri();
if (googleCloudSourceSupport.canHandle(uri)) {
return googleCloudSourceSupport.createTransportConfigCallback();
}
}
// Otherwise - legacy behaviour - use SshTransportConfigCallback for all
// repositories.
return buildSshTransportConfigCallback(environmentProperties);
}
private TransportConfigCallback buildSshTransportConfigCallback(
MultipleJGitEnvironmentProperties gitEnvironmentProperties) {
if (gitEnvironmentProperties.isIgnoreLocalSshSettings()) {
return new PropertiesBasedSshTransportConfigCallback(
gitEnvironmentProperties);
}
return new FileBasedSshTransportConfigCallback(gitEnvironmentProperties);
}
}

View File

@@ -114,4 +114,23 @@ public class CompositeClasspathTests {
}
@RunWith(ModifiedClassPathRunner.class)
@ClassPathExclusions("google-auth-library-oauth2-http-*.jar")
public static class GoogleAuthTests {
@Test
public void contextLoads() {
new WebApplicationContextRunner()
.withUserConfiguration(ConfigServerApplication.class)
.withPropertyValues("spring.profiles.active:test,composite",
"spring.jmx.enabled=false", "spring.config.name:configserver",
"spring.cloud.config.server.composite[0].uri:https://source.developers.google.com",
"spring.cloud.config.server.composite[0].type:git")
.run(context -> {
CompositeUtils.getCompositeTypeList(context.getEnvironment());
});
}
}
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2013-2019 the original author or authors.
*
* 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
*
* https://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.springframework.cloud.config.server.support;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import org.eclipse.jgit.api.TransportConfigCallback;
import org.eclipse.jgit.transport.SshTransport;
import org.eclipse.jgit.transport.Transport;
import org.eclipse.jgit.transport.TransportHttp;
import org.eclipse.jgit.transport.URIish;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.cloud.config.server.support.GoogleCloudSourceSupport.CredentialsProvider;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.atMost;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
/**
* @author Eduard Wirch
*/
public class GoogleCloudSourceSupportTests {
private static final String HTTPS_GOOGLE_CLOUD_SOURCE_REPO = "https://source.developers.google.com/r/somerepo";
private static final String HTTP_GOOGLE_CLOUD_SOURCE_REPO = "http://source.developers.google.com/r/somerepo";
private static final String SSH_GOOGLE_CLOUD_SOURCE_REPO = "ssh://source.developers.google.com/r/somerepo";
private static final String HTTPS_OTHER_REPO = "https://somehub.com/r/somerepo";
@Test
public void verifySetsAuthHeadersForHttpsGCSRepo() throws URISyntaxException {
Map<String, String> authHeaders = createAuthHeaders();
TransportConfigCallback callback = transportConfigCallbackWith(authHeaders);
TransportHttp transport = mockTransportHttp(HTTPS_GOOGLE_CLOUD_SOURCE_REPO);
Map<String, String> actualHeaders = recordSetHeaders(transport);
callback.configure(transport);
assertThat(actualHeaders).containsAllEntriesOf(authHeaders);
}
@Test
public void verifySetsAuthHeadersForHttpGCSRepo() throws URISyntaxException {
Map<String, String> authHeaders = createAuthHeaders();
TransportConfigCallback callback = transportConfigCallbackWith(authHeaders);
TransportHttp transport = mockTransportHttp(HTTP_GOOGLE_CLOUD_SOURCE_REPO);
Map<String, String> actualHeaders = recordSetHeaders(transport);
callback.configure(transport);
assertThat(actualHeaders).containsAllEntriesOf(authHeaders);
}
@Test
public void verifyDoesNothingForSshGCSRepo() throws URISyntaxException {
TransportConfigCallback callback = transportConfigCallbackWith(
createAuthHeaders());
TransportHttp transport = mockTransportHttp(SSH_GOOGLE_CLOUD_SOURCE_REPO);
callback.configure(transport);
verifyOnlyValidInteraction(transport);
}
@Test
public void verifyDoesNothingForHttpsOtherRepo() throws URISyntaxException {
TransportConfigCallback callback = transportConfigCallbackWith(
createAuthHeaders());
TransportHttp transport = mockTransportHttp(HTTPS_OTHER_REPO);
callback.configure(transport);
verifyOnlyValidInteraction(transport);
}
@Test
public void verifyDoesNothingForNonHttpTransports() throws URISyntaxException {
TransportConfigCallback callback = transportConfigCallbackWith(
createAuthHeaders());
Transport transport = mockSshTransport(SSH_GOOGLE_CLOUD_SOURCE_REPO);
callback.configure(transport);
verifyOnlyValidInteraction(transport);
}
private void verifyOnlyValidInteraction(Transport transport) {
// Actually, we don't care how often getURI() was invoked, simply "allow"
// invocation of getURI(), so verifyNoMoreInteractions() won't complain
// about getURI().
verify(transport, atMost(10000)).getURI();
verifyNoMoreInteractions(transport);
}
private Map<String, String> createAuthHeaders() {
Map<String, String> headers = new HashMap<>();
headers.put("WWW-Authorization", "user:password");
return headers;
}
private Map<String, String> recordSetHeaders(TransportHttp transport) {
Map<String, String> headers = new HashMap<>();
doAnswer(invocation -> {
headers.putAll(invocation.getArgument(0));
return null;
}).when(transport).setAdditionalHeaders(anyMap());
return headers;
}
private TransportHttp mockTransportHttp(String uri) throws URISyntaxException {
TransportHttp transport = Mockito.mock(TransportHttp.class);
when(transport.getURI()).thenReturn(new URIish(uri));
return transport;
}
@SuppressWarnings("SameParameterValue")
private Transport mockSshTransport(String uri) throws URISyntaxException {
Transport transport = Mockito.mock(SshTransport.class);
when(transport.getURI()).thenReturn(new URIish(uri));
return transport;
}
private TransportConfigCallback transportConfigCallbackWith(
Map<String, String> authHeaders) {
CredentialsProvider credentialsProvider = () -> authHeaders;
return new GoogleCloudSourceSupport()
.createTransportConfigCallback(credentialsProvider);
}
}