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

@@ -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);
}
}