diff --git a/agent/pom.xml b/agent/pom.xml index 4df5bce5a0..8049b7c15c 100644 --- a/agent/pom.xml +++ b/agent/pom.xml @@ -29,6 +29,10 @@ com.walmartlabs.concord concord-common + + com.walmartlabs.concord + concord-github-app-installation + com.walmartlabs.concord concord-client2 @@ -143,6 +147,11 @@ mockito-core test + + org.mockito + mockito-junit-jupiter + test + diff --git a/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java b/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java new file mode 100644 index 0000000000..b3f320a614 --- /dev/null +++ b/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java @@ -0,0 +1,135 @@ +package com.walmartlabs.concord.agent; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.typesafe.config.Config; +import com.walmartlabs.concord.agent.remote.ApiClientFactory; +import com.walmartlabs.concord.client2.ApiException; +import com.walmartlabs.concord.client2.SystemApi; +import com.walmartlabs.concord.common.AuthTokenProvider; +import com.walmartlabs.concord.common.ExternalAuthToken; +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import com.walmartlabs.concord.github.appinstallation.GitHubAppInstallation; +import com.walmartlabs.concord.sdk.Secret; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.Optional; + +public class AgentAuthTokenProvider implements AuthTokenProvider { + + private final List authTokenProviders; + + @Inject + public AgentAuthTokenProvider(ConcordServerTokenProvider concordProvider, + GitHubAppInstallation githubProvider, + AuthTokenProvider.OauthTokenProvider oauthTokenProvider) { + + this.authTokenProviders = List.of( + concordProvider, + githubProvider, + oauthTokenProvider + ); + } + + @Override + public boolean supports(URI repo, @Nullable Secret secret) { + return authTokenProviders.stream() + .anyMatch(p -> p.supports(repo, secret)); + } + + public Optional getToken(URI repo, @Nullable Secret secret) { + for (var k : authTokenProviders) { + if (k.supports(repo, secret)) { + return k.getToken(repo, secret); + } + } + + return Optional.empty(); + } + + public static class ConcordServerTokenProvider implements AuthTokenProvider { + + private static final String CFG_ENABLED = "externalTokenProvider.enabled"; + private static final String CFG_URL_PATTERN = "externalTokenProvider.urlPattern"; + + private final SystemApi systemApi; + private final MappingAuthConfig.ConcordServerAuthConfig auth; + + @Inject + public ConcordServerTokenProvider(ApiClientFactory apiClientFactory, Config config) { + this.auth = initAuth(config); + + try { + this.systemApi = new SystemApi(apiClientFactory.create(null)); + } catch (IOException e) { + throw new RuntimeException("Error initializing System API client", e); + } + } + + private static MappingAuthConfig.ConcordServerAuthConfig initAuth(Config config) { + if (!config.hasPath(CFG_ENABLED) || !config.getBoolean(CFG_ENABLED)) { + return null; + } + + return config.hasPath(CFG_URL_PATTERN) + ? MappingAuthConfig.ConcordServerAuthConfig.builder() + .urlPattern(MappingAuthConfig.assertBaseUrlPattern(config.getString(CFG_URL_PATTERN))) + .build() + : null; + } + + @Override + public boolean supports(URI repo, @Nullable Secret secret) { + if (auth == null) { + return false; + } + + // Maybe support secret input? This requires elevated api access permission + + return auth.canHandle(repo); + } + + @Override + public Optional getToken(URI repo, @Nullable Secret secret) { + try { + var resp = systemApi.getExternalToken(repo); + + return Optional.of(ExternalAuthToken.StaticToken.builder() + .token(resp.getToken()) + .username(resp.getUsername()) + .expiresAt(resp.getExpiresAt()) + .build()); + } catch (ApiException e) { + if (e.getCode() == 403) { + // User needs externalTokenLookup permission + throw new RuntimeException("No permission to get auth token from concord server."); + } + + throw new RuntimeException("Error retrieving concord-provided auth token", e); + } + } + } + +} diff --git a/agent/src/main/java/com/walmartlabs/concord/agent/AgentModule.java b/agent/src/main/java/com/walmartlabs/concord/agent/AgentModule.java index 81ad08b741..cc6e06f1e1 100644 --- a/agent/src/main/java/com/walmartlabs/concord/agent/AgentModule.java +++ b/agent/src/main/java/com/walmartlabs/concord/agent/AgentModule.java @@ -30,7 +30,9 @@ import com.walmartlabs.concord.agent.remote.ApiClientFactory; import com.walmartlabs.concord.agent.remote.QueueClientProvider; import com.walmartlabs.concord.common.ObjectMapperProvider; +import com.walmartlabs.concord.common.cfg.OauthTokenConfig; import com.walmartlabs.concord.config.ConfigModule; +import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig; import com.walmartlabs.concord.server.queueclient.QueueClient; import javax.inject.Named; @@ -59,6 +61,10 @@ public void configure(Binder binder) { binder.bind(DockerConfiguration.class).in(SINGLETON); binder.bind(RuntimeConfiguration.class).asEagerSingleton(); binder.bind(GitConfiguration.class).in(SINGLETON); + binder.bind(OauthTokenConfig.class).to(GitConfiguration.class).in(SINGLETON); + binder.bind(GitHubConfiguration.class).in(SINGLETON); + binder.bind(GitHubAppInstallationConfig.class).to(GitHubConfiguration.class).in(SINGLETON); + binder.bind(AgentAuthTokenProvider.ConcordServerTokenProvider.class).in(SINGLETON); binder.bind(ImportConfiguration.class).in(SINGLETON); binder.bind(PreForkConfiguration.class).in(SINGLETON); binder.bind(RepositoryCacheConfiguration.class).in(SINGLETON); diff --git a/agent/src/main/java/com/walmartlabs/concord/agent/RepositoryManager.java b/agent/src/main/java/com/walmartlabs/concord/agent/RepositoryManager.java index e743d4203d..93a72330d5 100644 --- a/agent/src/main/java/com/walmartlabs/concord/agent/RepositoryManager.java +++ b/agent/src/main/java/com/walmartlabs/concord/agent/RepositoryManager.java @@ -34,7 +34,6 @@ import javax.inject.Inject; import java.io.IOException; import java.nio.file.Path; -import java.util.Arrays; import java.util.List; public class RepositoryManager { @@ -46,29 +45,33 @@ public class RepositoryManager { private final RepositoryCache repositoryCache; private final GitConfiguration gitCfg; + @Inject public RepositoryManager(SecretClient secretClient, GitConfiguration gitCfg, RepositoryCacheConfiguration cacheCfg, ObjectMapper objectMapper, - DependencyManager dependencyManager) throws IOException { + DependencyManager dependencyManager, + AgentAuthTokenProvider agentAuthTokenProvider) throws IOException { this.secretClient = secretClient; this.gitCfg = gitCfg; GitClientConfiguration clientCfg = GitClientConfiguration.builder() - .oauthToken(gitCfg.getToken()) + .oauthToken(gitCfg.getOauthToken()) .defaultOperationTimeout(gitCfg.getDefaultOperationTimeout()) .fetchTimeout(gitCfg.getFetchTimeout()) .httpLowSpeedLimit(gitCfg.getHttpLowSpeedLimit()) .httpLowSpeedTime(gitCfg.getHttpLowSpeedTime()) + .allowedSchemes(gitCfg.getAllowedSchemes()) .sshTimeout(gitCfg.getSshTimeout()) .sshTimeoutRetryCount(gitCfg.getSshTimeoutRetryCount()) .build(); - List providers = Arrays.asList(new MavenRepositoryProvider(dependencyManager), new GitCliRepositoryProvider(clientCfg)); - this.providers = new RepositoryProviders(providers); - + this.providers = new RepositoryProviders(List.of( + new MavenRepositoryProvider(dependencyManager), + new GitCliRepositoryProvider(clientCfg, agentAuthTokenProvider) + )); this.repositoryCache = new RepositoryCache(cacheCfg.getCacheDir(), cacheCfg.getInfoDir(), cacheCfg.getLockTimeout(), diff --git a/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitConfiguration.java b/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitConfiguration.java index 19d63aa9a7..efcb0a07cd 100644 --- a/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitConfiguration.java +++ b/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitConfiguration.java @@ -21,15 +21,21 @@ */ import com.typesafe.config.Config; +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import com.walmartlabs.concord.common.cfg.OauthTokenConfig; import javax.inject.Inject; import java.time.Duration; +import java.util.List; +import java.util.Optional; import static com.walmartlabs.concord.agent.cfg.Utils.getStringOrDefault; -public class GitConfiguration { +public class GitConfiguration implements OauthTokenConfig { private final String token; + private final String oauthUsername; + private final String oauthUrlPattern; private final boolean shallowClone; private final boolean checkAlreadyFetched; private final Duration defaultOperationTimeout; @@ -39,10 +45,14 @@ public class GitConfiguration { private final Duration sshTimeout; private final int sshTimeoutRetryCount; private final boolean skip; + private final List allowedSchemes; + private final List authConfigs; @Inject public GitConfiguration(Config cfg) { this.token = getStringOrDefault(cfg, "git.oauth", () -> null); + this.oauthUsername = getStringOrDefault(cfg, "git.oauthUsername", () -> null); + this.oauthUrlPattern = getStringOrDefault(cfg, "git.oauthUrlPattern", () -> null); this.shallowClone = cfg.getBoolean("git.shallowClone"); this.checkAlreadyFetched = cfg.getBoolean("git.checkAlreadyFetched"); this.defaultOperationTimeout = cfg.getDuration("git.defaultOperationTimeout"); @@ -52,10 +62,23 @@ public GitConfiguration(Config cfg) { this.sshTimeout = cfg.getDuration("git.sshTimeout"); this.sshTimeoutRetryCount = cfg.getInt("git.sshTimeoutRetryCount"); this.skip = cfg.getBoolean("git.skip"); + this.allowedSchemes = cfg.getStringList("git.allowedSchemes"); + this.authConfigs = cfg.getConfigList("git.systemAuth"); } - public String getToken() { - return token; + @Override + public Optional getOauthToken() { + return Optional.ofNullable(token); + } + + @Override + public Optional getOauthUsername() { + return Optional.ofNullable(oauthUsername); + } + + @Override + public Optional getOauthUrlPattern() { + return Optional.ofNullable(oauthUrlPattern); } public boolean isShallowClone() { @@ -93,4 +116,47 @@ public int getSshTimeoutRetryCount() { public boolean isSkip() { return skip; } + + public List getAllowedSchemes() { return allowedSchemes; } + + public List getSystemAuth() { + return authConfigs.stream() + .map(o -> { + AuthSource type = AuthSource.valueOf(o.getString("type").toUpperCase()); + + return (AuthConfig) switch (type) { + case OAUTH_TOKEN -> OauthConfig.from(o); + }; + }) + .map(AuthConfig::toGitAuth) + .toList(); + + } + + enum AuthSource { + OAUTH_TOKEN + } + + public interface AuthConfig { + MappingAuthConfig toGitAuth(); + } + + public record OauthConfig(String urlPattern, String token) implements AuthConfig { + + static OauthConfig from(Config cfg) { + return new OauthConfig( + cfg.getString("urlPattern"), + cfg.getString("token") + ); + } + + @Override + public MappingAuthConfig.OauthAuthConfig toGitAuth() { + return MappingAuthConfig.OauthAuthConfig.builder() + .urlPattern(MappingAuthConfig.assertBaseUrlPattern(this.urlPattern())) + .token(this.token()) + .build(); + } + } + } diff --git a/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitHubConfiguration.java b/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitHubConfiguration.java new file mode 100644 index 0000000000..ab037d6ed6 --- /dev/null +++ b/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitHubConfiguration.java @@ -0,0 +1,63 @@ +package com.walmartlabs.concord.agent.cfg; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2019 Walmart 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. + * ===== + */ + +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig; + +import javax.inject.Inject; +import java.time.Duration; +import java.util.List; + +public class GitHubConfiguration implements GitHubAppInstallationConfig { + + private static final String CFG_APP_INSTALLATION = "github.appInstallation"; + + private final GitHubAppInstallationConfig appInstallation; + + @Inject + public GitHubConfiguration(com.typesafe.config.Config config) { + if (config.hasPath(CFG_APP_INSTALLATION)) { + var raw = config.getConfig(CFG_APP_INSTALLATION); + this.appInstallation = GitHubAppInstallationConfig.fromConfig(raw); + } else { + this.appInstallation = GitHubAppInstallationConfig.builder() + .authConfigs(List.of()) + .build(); + } + } + + @Override + public List getAuthConfigs() { + return appInstallation.getAuthConfigs(); + } + + @Override + public Duration getSystemAuthCacheDuration() { + return appInstallation.getSystemAuthCacheDuration(); + } + + @Override + public long getSystemAuthCacheMaxWeight() { + return appInstallation.getSystemAuthCacheMaxWeight(); + } + +} diff --git a/agent/src/main/resources/concord-agent.conf b/agent/src/main/resources/concord-agent.conf index 47846066f7..86c4f6ce7c 100644 --- a/agent/src/main/resources/concord-agent.conf +++ b/agent/src/main/resources/concord-agent.conf @@ -188,9 +188,28 @@ concord-agent { # if true, skip Git fetch, use workspace state only skip = false - # GitHub auth token to use when cloning repositories without explicitly configured authentication + allowedSchemes = [ "file", "https", "ssh", "classpath" ] + + # GitHub auth token to use when cloning repositories without explicitly + # configured authentication. Deprecated in favor of systemAuth list of + # tokens or service-specific app config (e.g. github) # oauth = "..." + # specific username to use for auth + # oauthUsername = "" + + # regex to match against git server's hostname + port + path so oauth + # token isn't used for and unexpected host + # oauthUrlPattern = "" + + # List of system-provided auth token configs + # { + # "token" = "...", + # "username" = "...", # optional, username to send with auth token + # "urlPattern" = "..." # required, regex to match against target git host + port + path + # } + systemAuth = [] + # use GIT's shallow clone shallowClone = true @@ -212,6 +231,32 @@ concord-agent { sshTimeout = "10 minutes" } + # github app settings. While this works on the agent, it's preferrable to + # get auth token from concord-server via externalTokenProvider + github { + # App installation settings. Multiple auth (private key) definitions are supported, + # as each is matched to a particular url pattern. + appInstallation { + # { + # type = "GITHUB_APP_INSTALLATION", + # urlPattern = "github.com", # regex + # username = "...", # optional, defaults to "x-access-token" + # apiUrl = "https://api.github.com", # github api url, usually *not* the same as the repo url host/path + # clientId = "...", + # privateKey = "/path/to/pk.pem" + # } + # or static oauth config. Not exactly a "GitHub App", but can do some + # API interactions and cloning. Less preferred to actual app. + # { + # type = "OAUTH_TOKEN", + # token = "...", + # username = "...", # optional, usually not necessary + # urlPattern = "..." # regex to match against git server's hostname + port + path + # } + auth = [] + } + } + imports { # base git url for imports src = "" @@ -223,6 +268,16 @@ concord-agent { ] } + externalTokenProvider { + enabled = false + # Regex matching URI host + port + path for providing lookup for + # external auth tokens. URI scheme is ignored. Requires externalTokenLookup + # permission for the client user. + # e.g. "github.com/my-org/" or "github.com/(orgA|orgB))/" + urlPattern = "concord-server" + } + + # configuration of "runners" -- JARs that are responsible for the actual process execution # common configuration for all runners diff --git a/agent/src/test/java/com/walmartlabs/concord/agent/AgentAuthTokenProviderTest.java b/agent/src/test/java/com/walmartlabs/concord/agent/AgentAuthTokenProviderTest.java new file mode 100644 index 0000000000..bd7fc22f75 --- /dev/null +++ b/agent/src/test/java/com/walmartlabs/concord/agent/AgentAuthTokenProviderTest.java @@ -0,0 +1,138 @@ +package com.walmartlabs.concord.agent; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.walmartlabs.concord.common.AuthTokenProvider; +import com.walmartlabs.concord.common.ExternalAuthToken; +import com.walmartlabs.concord.github.appinstallation.GitHubAppInstallation; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.URI; +import java.time.OffsetDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AgentAuthTokenProviderTest { + + @Mock + GitHubAppInstallation ghApp; + + @Mock + AuthTokenProvider.OauthTokenProvider oauthTokenProvider; + + @Mock + AgentAuthTokenProvider.ConcordServerTokenProvider concordServerTokenProvider; + + @Test + void testGitHubApp() { + when(ghApp.getToken(any(), any())). + thenReturn(Optional.of(ExternalAuthToken.SimpleToken.builder() + .token("gh-installation-token") + .expiresAt(OffsetDateTime.now().plusMinutes(60)) + .build())); + when(ghApp.supports(any(), any())).thenReturn(true); + + var provider = new AgentAuthTokenProvider(concordServerTokenProvider, ghApp, oauthTokenProvider); + + // -- + + assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null)); + var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null); + + // -- + + assertTrue(o.isPresent()); + var result = assertInstanceOf(ExternalAuthToken.class, o.get()); + assertEquals("gh-installation-token", result.token()); + } + + @Test + void testOauth() { + when(oauthTokenProvider.supports(any(), any())).thenReturn(true); + when(oauthTokenProvider.getToken(any(), any())) + .thenReturn(Optional.of(ExternalAuthToken.StaticToken.builder() + .token("oauth-token") + .build())); + + var provider = new AgentAuthTokenProvider(concordServerTokenProvider, ghApp, oauthTokenProvider); + + // -- + + assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null)); + var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null); + + // -- + + assertTrue(o.isPresent()); + var result = assertInstanceOf(ExternalAuthToken.class, o.get()); + assertEquals("oauth-token", result.token()); + } + + @Test + void testConcord() { + when(concordServerTokenProvider.supports(any(), any())).thenReturn(true); + when(concordServerTokenProvider.getToken(any(), any())) + .thenReturn(Optional.of(ExternalAuthToken.StaticToken.builder() + .token("token-from-concord") + .build())); + + var provider = new AgentAuthTokenProvider(concordServerTokenProvider, ghApp, oauthTokenProvider); + + // -- + + assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null)); + var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null); + + // -- + + assertTrue(o.isPresent()); + var result = assertInstanceOf(ExternalAuthToken.class, o.get()); + assertEquals("token-from-concord", result.token()); + } + + @Test + void testNoAuth() { + when(ghApp.supports(any(), any())).thenReturn(false); + when(oauthTokenProvider.supports(any(), any())).thenReturn(false); + + var provider = new AgentAuthTokenProvider(concordServerTokenProvider, ghApp, oauthTokenProvider); + + // -- + + assertFalse(provider.supports(URI.create("https://github.local/owner/repo.git"), null)); + var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null); + + // -- + + assertFalse(o.isPresent()); + } + +} diff --git a/agent/src/test/java/com/walmartlabs/concord/agent/ConcordServerTokenProviderTest.java b/agent/src/test/java/com/walmartlabs/concord/agent/ConcordServerTokenProviderTest.java new file mode 100644 index 0000000000..ff82edc8fe --- /dev/null +++ b/agent/src/test/java/com/walmartlabs/concord/agent/ConcordServerTokenProviderTest.java @@ -0,0 +1,149 @@ +package com.walmartlabs.concord.agent; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.typesafe.config.ConfigFactory; +import com.walmartlabs.concord.agent.remote.ApiClientFactory; +import com.walmartlabs.concord.client2.ApiClient; +import com.walmartlabs.concord.common.ExternalAuthToken; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConcordServerTokenProviderTest { + + @Mock + ApiClientFactory acf; + + @Mock + HttpClient httpClient; + + @Mock + HttpResponse httpResponse; + + @Test + void testEnabled() throws Exception { + when(acf.create(null)).thenReturn(new ApiClient(httpClient)); + when(httpClient.send(any(), any())).thenReturn(httpResponse); + + new ApiClient(httpClient); + + when(httpResponse.statusCode()).thenReturn(200); + when(httpResponse.headers()).thenReturn(HttpHeaders.of(Map.of(), (a, b) -> true)); + when(httpResponse.body()).thenReturn(new ByteArrayInputStream(""" + { + "token": "token-from-concord", + "expires_at": "2099-12-31T23:59:59.123Z" + }""".getBytes())); + + var config = ConfigFactory.parseString(""" + { + "externalTokenProvider" { + "enabled" = true, + "urlPattern" = "github.local" + } + }"""); + + var provider = new AgentAuthTokenProvider.ConcordServerTokenProvider(acf, config); + + // -- + + assertFalse(provider.supports(URI.create("https://another.local/owner/repo.git"), null)); + assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null)); + + var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null); + + // -- + + assertTrue(o.isPresent()); + var result = assertInstanceOf(ExternalAuthToken.StaticToken.class, o.get()); + assertEquals("token-from-concord", result.token()); + } + @Test + void testNoPermission() throws Exception { + when(acf.create(null)).thenReturn(new ApiClient(httpClient)); + when(httpClient.send(any(), any())).thenReturn(httpResponse); + + new ApiClient(httpClient); + + when(httpResponse.statusCode()).thenReturn(403); + when(httpResponse.headers()).thenReturn(HttpHeaders.of(Map.of(), (a, b) -> true)); + when(httpResponse.body()).thenReturn(new ByteArrayInputStream("not enough permission".getBytes())); + + var config = ConfigFactory.parseString(""" + { + "externalTokenProvider" { + "enabled" = true, + "urlPattern" = "github.local" + } + }"""); + + var provider = new AgentAuthTokenProvider.ConcordServerTokenProvider(acf, config); + + assertFalse(provider.supports(URI.create("https://another.local/owner/repo.git"), null)); + assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null)); + + // -- + + var ex = assertThrows(RuntimeException.class, () -> provider.getToken(URI.create("https://github.local/owner/repo.git"), null)); + + // -- + + assertTrue(ex.getMessage().contains("No permission to get auth token from concord server")); + } + + @Test + void testDisabled() throws Exception { + when(acf.create(null)).thenReturn(new ApiClient(httpClient)); + + var config = ConfigFactory.parseString(""" + { + "externalTokenProvider" { + "enabled" = false, + "urlPattern" = "github.local" + } + }"""); + + var provider = new AgentAuthTokenProvider.ConcordServerTokenProvider(acf, config); + + // -- + + assertFalse(provider.supports(URI.create("https://another.local/owner/repo.git"), null)); + assertFalse(provider.supports(URI.create("https://github.local/owner/repo.git"), null)); + } +} diff --git a/cli/src/main/java/com/walmartlabs/concord/cli/runner/CliRepositoryExporter.java b/cli/src/main/java/com/walmartlabs/concord/cli/runner/CliRepositoryExporter.java index 7237f1e904..5b851a9049 100644 --- a/cli/src/main/java/com/walmartlabs/concord/cli/runner/CliRepositoryExporter.java +++ b/cli/src/main/java/com/walmartlabs/concord/cli/runner/CliRepositoryExporter.java @@ -20,17 +20,22 @@ * ===== */ +import com.walmartlabs.concord.common.AuthTokenProvider; +import com.walmartlabs.concord.common.ExternalAuthToken; import com.walmartlabs.concord.imports.Import; import com.walmartlabs.concord.imports.RepositoryExporter; import com.walmartlabs.concord.repository.*; import com.walmartlabs.concord.sdk.Secret; +import javax.annotation.Nullable; import java.io.UnsupportedEncodingException; +import java.net.URI; import java.net.URLEncoder; import java.nio.file.Path; import java.time.Duration; -import java.util.Collections; +import java.util.List; import java.util.Objects; +import java.util.Optional; public class CliRepositoryExporter implements RepositoryExporter { @@ -50,7 +55,6 @@ public CliRepositoryExporter(Path repoCacheDir) { this.repoCacheDir = repoCacheDir; GitClientConfiguration clientCfg = GitClientConfiguration.builder() - .oauthToken(null) .defaultOperationTimeout(DEFAULT_OPERATION_TIMEOUT) .fetchTimeout(FETCH_TIMEOUT) .httpLowSpeedLimit(HTTP_LOW_SPEED_LIMIT) @@ -59,7 +63,19 @@ public CliRepositoryExporter(Path repoCacheDir) { .sshTimeoutRetryCount(SSH_TIMEOUT_RETRY_COUNT) .build(); - this.providers = new RepositoryProviders(Collections.singletonList(new GitCliRepositoryProvider(clientCfg))); + AuthTokenProvider authProvider = new AuthTokenProvider() { + @Override + public boolean supports(URI repo, @Nullable Secret secret) { + return false; + } + + @Override + public Optional getToken(URI repo, @Nullable Secret secret) throws RepositoryException { + throw new UnsupportedOperationException("Not supported"); + } + }; + + this.providers = new RepositoryProviders(List.of(new GitCliRepositoryProvider(clientCfg, authProvider))); } @Override diff --git a/common/pom.xml b/common/pom.xml index 042fe17b50..e7d0c335bf 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -49,6 +49,11 @@ commons-validator provided + + org.immutables + value + provided + com.fasterxml.jackson.core jackson-databind @@ -92,6 +97,16 @@ junit-jupiter-api test + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + diff --git a/common/src/main/java/com/walmartlabs/concord/common/AuthTokenProvider.java b/common/src/main/java/com/walmartlabs/concord/common/AuthTokenProvider.java new file mode 100644 index 0000000000..612396a5cf --- /dev/null +++ b/common/src/main/java/com/walmartlabs/concord/common/AuthTokenProvider.java @@ -0,0 +1,143 @@ +package com.walmartlabs.concord.common; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import com.walmartlabs.concord.common.cfg.OauthTokenConfig; +import com.walmartlabs.concord.common.secret.BinaryDataSecret; +import com.walmartlabs.concord.sdk.Secret; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +public interface AuthTokenProvider { + + /** + * @return {@code true} if this the given repo URI and secret are compatible + * with this provider's {@link #getToken(URI, Secret)} method, + * {@code false} otherwise. + */ + boolean supports(URI repo, @Nullable Secret secret); + + Optional getToken(URI repo, @Nullable Secret secret); + + default URI addUserInfoToUri(URI repo, @Nullable Secret secret) { + if (!supports(repo, secret)) { + // not compatible with auth provider(s) + return repo; + } + + return getToken(repo, secret) + .map(expiringToken -> { + var token = expiringToken.token(); + var userInfo = expiringToken.username() != null + ? expiringToken.username() + ":" + token + : token; + + try { + return new URI(repo.getScheme(), userInfo, repo.getHost(), + repo.getPort(), repo.getPath(), repo.getQuery(), repo.getFragment()); + } catch (URISyntaxException e) { + // TODO add log? + } + + return null; + }) + .orElse(repo); + } + + @SuppressWarnings("ClassCanBeRecord") + class OauthTokenProvider implements AuthTokenProvider { + // >0 length, printable ascii (no newlines, etc) + private static final Pattern BASIC_STRING_PTN = Pattern.compile("[ -~]+"); + private final List authConfigs; + + @Inject + public OauthTokenProvider(OauthTokenConfig config) { + this.authConfigs = toConfigList(config); + } + + @Override + public boolean supports(URI repo, @Nullable Secret secret) { + return validateSecret(secret) || systemSupports(repo); + } + + @Override + public Optional getToken(URI repo, @Nullable Secret secret) { + if (secret != null) { + if (secret instanceof BinaryDataSecret bds) { + return Optional.of(ExternalAuthToken.StaticToken.builder() + .token(new String(bds.getData())) + .build()); + } else { + return Optional.empty(); + } + } + + return authConfigs.stream() + .filter(auth -> auth.canHandle(repo)) + .filter(MappingAuthConfig.OauthAuthConfig.class::isInstance) + .map(MappingAuthConfig.OauthAuthConfig.class::cast) + .findFirst() + .map(auth -> ExternalAuthToken.StaticToken.builder() + .token(auth.token()) + .username(auth.username().orElse(null)) + .build()); + } + + private boolean validateSecret(Secret secret) { + if (secret == null) { + return false; + } + + if (!(secret instanceof BinaryDataSecret bds)) { + // this class is not the place for handling key pairs or username/password + return false; + } else { + var data = new String(bds.getData()); + return BASIC_STRING_PTN.matcher(data).matches(); + } + } + + private boolean systemSupports(URI repoUri) { + return authConfigs.stream().anyMatch(auth -> auth.canHandle(repoUri)); + } + + private static List toConfigList(OauthTokenConfig config) { + var token = config.getOauthToken().orElse(null); + + if (token == null || token.isBlank() && config.getSystemAuth().isEmpty()) { + return config.getSystemAuth(); + } + + return List.of(MappingAuthConfig.OauthAuthConfig.builder() + .token(token) + .username(config.getOauthUsername()) + .urlPattern(MappingAuthConfig.assertBaseUrlPattern(config.getOauthUrlPattern().orElse(".*")))// for backwards compat with git.oauth + .build()); + } + } +} diff --git a/common/src/main/java/com/walmartlabs/concord/common/ExternalAuthToken.java b/common/src/main/java/com/walmartlabs/concord/common/ExternalAuthToken.java new file mode 100644 index 0000000000..6087f861d6 --- /dev/null +++ b/common/src/main/java/com/walmartlabs/concord/common/ExternalAuthToken.java @@ -0,0 +1,90 @@ +package com.walmartlabs.concord.common; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.immutables.value.Value; + +import javax.annotation.Nullable; +import java.time.Duration; +import java.time.OffsetDateTime; + +@JsonDeserialize(as = ImmutableSimpleToken.class) +public interface ExternalAuthToken { + + @JsonProperty("token") + String token(); + + @Nullable + @JsonProperty("username") + String username(); + + @Nullable + @JsonProperty("expires_at") + // GitHub gives time in seconds, but most parsers (e.g. jackson) expect milliseconds + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS]X") + OffsetDateTime expiresAt(); + + @Value.Default + @JsonIgnore + default long secondsUntilExpiration() { + if (expiresAt() == null) { + return Long.MAX_VALUE; + } + + var d = Duration.between(OffsetDateTime.now(), expiresAt()); + return d.getSeconds(); + } + + /** + * Basic implementation of an expiring token. + */ + @Value.Immutable + @Value.Style(jdkOnly = true) + interface SimpleToken extends ExternalAuthToken { + static ImmutableSimpleToken.Builder builder() { + return ImmutableSimpleToken.builder(); + } + } + + /** + * A token that effectively never expires. + */ + @Value.Immutable + @Value.Style(jdkOnly = true) + interface StaticToken extends ExternalAuthToken { + + @Value.Default + @Nullable + @Override + default OffsetDateTime expiresAt() { + return null; + } + + static ImmutableStaticToken.Builder builder() { + return ImmutableStaticToken.builder(); + } + } + +} diff --git a/common/src/main/java/com/walmartlabs/concord/common/SystemExternalTokenProvider.java b/common/src/main/java/com/walmartlabs/concord/common/SystemExternalTokenProvider.java new file mode 100644 index 0000000000..9c00d7019b --- /dev/null +++ b/common/src/main/java/com/walmartlabs/concord/common/SystemExternalTokenProvider.java @@ -0,0 +1,25 @@ +package com.walmartlabs.concord.common; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +public interface SystemExternalTokenProvider extends AuthTokenProvider { + +} diff --git a/common/src/main/java/com/walmartlabs/concord/common/cfg/MappingAuthConfig.java b/common/src/main/java/com/walmartlabs/concord/common/cfg/MappingAuthConfig.java new file mode 100644 index 0000000000..58095c27c7 --- /dev/null +++ b/common/src/main/java/com/walmartlabs/concord/common/cfg/MappingAuthConfig.java @@ -0,0 +1,80 @@ +package com.walmartlabs.concord.common.cfg; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import org.immutables.value.Value; + +import java.net.URI; +import java.util.Optional; +import java.util.regex.Pattern; + +public interface MappingAuthConfig { + + /** Regex matching the host, optional port and path of a Git repository URL. */ + Pattern urlPattern(); + + /** + * Username to use for authentication with a provided token. Some services + * (e.g. GitHub API for app installation) require a specific username. Others + * (e.g. GitHub API for personal access tokens) accept just the token and no username + */ + Optional username(); + + /** + * For compatibility with a {@link MappingAuthConfig} instance, a URI must match the + * {@link #urlPattern()} regex. The regex may match against the path to support + * either a Git host behind a reverse proxy or restricting the auth to specific + * org/repo patterns. + * @return {@code true} if this provider can handle the given repo URI, {@code false} otherwise. + */ + default boolean canHandle(URI repo) { + var port = (repo.getPort() == -1 ? "" : (":" + repo.getPort())); + var path = (repo.getPath() == null ? "" : repo.getPath()); + var repoHostPortAndPath = repo.getHost() + port + path; + + return repoHostPortAndPath.matches(urlPattern() + ".*"); + } + + static Pattern assertBaseUrlPattern(String pattern) { + return pattern.endsWith(".*") + ? Pattern.compile(pattern) + : Pattern.compile(pattern + ".*"); + } + + @Value.Immutable + @Value.Style(jdkOnly = true) + interface OauthAuthConfig extends MappingAuthConfig { + String token(); + + static ImmutableOauthAuthConfig.Builder builder() { + return ImmutableOauthAuthConfig.builder(); + } + } + + @Value.Immutable + @Value.Style(jdkOnly = true) + interface ConcordServerAuthConfig extends MappingAuthConfig { + static ImmutableConcordServerAuthConfig.Builder builder() { + return ImmutableConcordServerAuthConfig.builder(); + } + } + +} diff --git a/common/src/main/java/com/walmartlabs/concord/common/cfg/OauthTokenConfig.java b/common/src/main/java/com/walmartlabs/concord/common/cfg/OauthTokenConfig.java new file mode 100644 index 0000000000..5fb708b272 --- /dev/null +++ b/common/src/main/java/com/walmartlabs/concord/common/cfg/OauthTokenConfig.java @@ -0,0 +1,36 @@ +package com.walmartlabs.concord.common.cfg; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import java.util.List; +import java.util.Optional; + +public interface OauthTokenConfig { + + Optional getOauthToken(); + + Optional getOauthUsername(); + + Optional getOauthUrlPattern(); + + List getSystemAuth(); + +} diff --git a/common/src/test/java/com/walmartlabs/concord/common/AuthTokenProviderTest.java b/common/src/test/java/com/walmartlabs/concord/common/AuthTokenProviderTest.java new file mode 100644 index 0000000000..a1be8bb08d --- /dev/null +++ b/common/src/test/java/com/walmartlabs/concord/common/AuthTokenProviderTest.java @@ -0,0 +1,155 @@ +package com.walmartlabs.concord.common; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import com.walmartlabs.concord.common.cfg.OauthTokenConfig; +import com.walmartlabs.concord.common.secret.BinaryDataSecret; +import com.walmartlabs.concord.common.secret.UsernamePassword; +import org.immutables.value.Value; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthTokenProviderTest { + + private static final byte[] SECRET_BYTES = "abc123".getBytes(StandardCharsets.UTF_8); + private static final String MOCK_TOKEN = "mock-token"; + private static final String MOCK_USERNAME = "mock-username"; + private static final String VALID_REPO = "https://github.local/owner/repo.git"; + + @Mock + BinaryDataSecret binaryDataSecret; + + @Mock + UsernamePassword usernamePassword; + + @Mock + MappingAuthConfig.OauthAuthConfig oauth; + + @Mock + TestOauthTokenConfig oauthTokenConfig; + + @Test + void testSingleOauth() { + // the "old" config approach + when(oauthTokenConfig.getOauthToken()).thenReturn(Optional.of(MOCK_TOKEN)); + when(oauthTokenConfig.getOauthUrlPattern()).thenReturn(Optional.of("github\\.local")); + when(oauthTokenConfig.getOauthUsername()).thenReturn(Optional.of(MOCK_USERNAME)); + + executeWithoutSecret(oauthTokenConfig); + + verify(oauthTokenConfig, times(1)).getOauthUrlPattern(); // retrieved once and stored + } + + @Test + void testSystemAuth() { + when(oauth.canHandle(any())).thenCallRealMethod(); + when(oauth.urlPattern()).thenReturn(Pattern.compile("github\\.local")); + when(oauth.token()).thenReturn(MOCK_TOKEN); + when(oauth.username()).thenReturn(Optional.of(MOCK_USERNAME)); + + var cfg = TestOauthTokenConfig.builder() + .addSystemAuth(oauth) + .build(); + + executeWithoutSecret(cfg); + + verify(oauth, times(12)).canHandle(any()); + } + + void executeWithoutSecret(OauthTokenConfig cfg) { + var provider = new AuthTokenProvider.OauthTokenProvider(cfg); + + assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null)); + assertTrue(provider.supports(URI.create("https://github.local/owner/repo"), null)); + assertTrue(provider.supports(URI.create("https://github.local/owner/repo/"), null)); + assertFalse(provider.supports(URI.create("https://elsewhere.local/owner/repo.git"), null)); + assertFalse(provider.supports(URI.create("https://elsewhere.local/owner/repo"), null)); + + assertEquals(MOCK_TOKEN, provider.getToken(URI.create("https://github.local/owner/repo.git"), null).map(ExternalAuthToken::token).orElse(null)); + assertEquals(MOCK_TOKEN, provider.getToken(URI.create("https://github.local/owner/repo"), null).map(ExternalAuthToken::token).orElse(null)); + assertEquals(MOCK_TOKEN, provider.getToken(URI.create("https://github.local/owner/repo/"), null).map(ExternalAuthToken::token).orElse(null)); + assertFalse(provider.getToken(URI.create("https://elsewhere.local/owner/repo.git"), null).isPresent()); + assertFalse(provider.getToken(URI.create("https://elsewhere.local/owner/repo"), null).isPresent()); + + var enriched = provider.addUserInfoToUri(URI.create("https://github.local/owner/repo.git"), null); + assertEquals(MOCK_USERNAME + ":" + MOCK_TOKEN, enriched.getUserInfo()); + assertEquals("https://" + MOCK_USERNAME + ":" + MOCK_TOKEN + "@github.local/owner/repo.git", enriched.toString()); + } + + @Test + void testUsernamePassword() { + var cfg = TestOauthTokenConfig.builder().build(); + var provider = new AuthTokenProvider.OauthTokenProvider(cfg); + + assertFalse(provider.supports(URI.create(VALID_REPO), usernamePassword)); + } + + @Test + void testWithSecret() { + var cfg = TestOauthTokenConfig.builder() + .addSystemAuth(oauth) // won't be used + .build(); + + executeWithSecret(cfg); + } + + @Test + void testWithSecretNoDefault() { + var cfg = TestOauthTokenConfig.builder().build(); + + executeWithSecret(cfg); + } + + private void executeWithSecret(TestOauthTokenConfig cfg) { + var provider = new AuthTokenProvider.OauthTokenProvider(cfg); + + when(binaryDataSecret.getData()).thenReturn(SECRET_BYTES); + assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), binaryDataSecret)); + + verify(oauth, never()).token(); // prove it wasn't used + verify(binaryDataSecret, times(1)).getData(); + } + + @Value.Immutable + interface TestOauthTokenConfig extends OauthTokenConfig { + static ImmutableTestOauthTokenConfig.Builder builder() { + return ImmutableTestOauthTokenConfig.builder(); + } + } +} diff --git a/common/src/test/java/com/walmartlabs/concord/common/ExternalAuthTokenTest.java b/common/src/test/java/com/walmartlabs/concord/common/ExternalAuthTokenTest.java new file mode 100644 index 0000000000..c365f2f125 --- /dev/null +++ b/common/src/test/java/com/walmartlabs/concord/common/ExternalAuthTokenTest.java @@ -0,0 +1,123 @@ +package com.walmartlabs.concord.common; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ExternalAuthTokenTest { + + static final String MOCK_TOKEN = "mock-token"; + static final ObjectMapper MAPPER = new ObjectMapperProvider().get(); + + @Test + void testExpiration() { + var externalToken = ExternalAuthToken.SimpleToken.builder() + .token(MOCK_TOKEN) + .expiresAt(OffsetDateTime.now().minusSeconds((100))) + .build(); + + assertTrue(externalToken.secondsUntilExpiration() < 0); + } + + @Test + void testStaticExpiration() { + var externalToken = ExternalAuthToken.StaticToken.builder() + .token(MOCK_TOKEN) + .build(); + + assertEquals(MOCK_TOKEN, externalToken.token()); + assertEquals(Long.MAX_VALUE, externalToken.secondsUntilExpiration()); + } + + @Test + void testMinimalDeserialization() throws JsonProcessingException { + var minimalFromJson = MAPPER.readValue(""" + { + "token": "mock-token" + } + """, ExternalAuthToken.class); + + assertEquals(MOCK_TOKEN, minimalFromJson.token()); + assertEquals(Long.MAX_VALUE, minimalFromJson.secondsUntilExpiration()); + } + + @Test + void testFullDeserialization() throws JsonProcessingException { + var fullFromJson = MAPPER.readValue(""" + { + "token": "mock-token", + "expires_at": "2099-12-31T23:59:59Z", + "username": "mock-username" + } + """, ExternalAuthToken.class); + + assertEquals(MOCK_TOKEN, fullFromJson.token()); + assertEquals("mock-username", fullFromJson.username()); + var dt = fullFromJson.expiresAt(); + assertNotNull(dt); + assertEquals(2099, dt.getYear()); + } + + @Test + void testFullDeserializationMillis() throws JsonProcessingException { + var fullFromJson = MAPPER.readValue(""" + { + "token": "mock-token", + "expires_at": "2099-12-31T23:59:59.123Z", + "username": "mock-username" + } + """, ExternalAuthToken.class); + + assertEquals(MOCK_TOKEN, fullFromJson.token()); + var dt = fullFromJson.expiresAt(); + assertNotNull(dt); + assertEquals(2099, dt.getYear()); + assertEquals(123, dt.getNano() / 1_000_000); + } + + @Test + void testDateSerializationSecondsToMillis() throws JsonProcessingException { + var json = MAPPER.writeValueAsString(ExternalAuthToken.SimpleToken.builder() + .token(MOCK_TOKEN) + .expiresAt(OffsetDateTime.parse("2099-12-31T23:59:59Z")) + .build()); + + assertTrue(json.contains("23:59:59.000Z")); + } + + @Test + void testDateSerializationMillis() throws JsonProcessingException { + var json = MAPPER.writeValueAsString(ExternalAuthToken.SimpleToken.builder() + .token(MOCK_TOKEN) + .expiresAt(OffsetDateTime.parse("2099-12-31T23:59:59.123Z")) + .build()); + + assertTrue(json.contains("23:59:59.123Z")); + } +} diff --git a/github-app-installation/pom.xml b/github-app-installation/pom.xml new file mode 100644 index 0000000000..6c5abefb44 --- /dev/null +++ b/github-app-installation/pom.xml @@ -0,0 +1,156 @@ + + + + 4.0.0 + + + com.walmartlabs.concord + parent + 2.33.4-SNAPSHOT + ../pom.xml + + + concord-github-app-installation + jar + + ${project.groupId}:${project.artifactId} + + + + com.walmartlabs.concord + concord-sdk + + + com.walmartlabs.concord + concord-common + + + io.takari.bpm + bpm-engine-impl + + + javax.validation + validation-api + provided + + + org.slf4j + slf4j-api + provided + + + com.typesafe + config + + + com.nimbusds + nimbus-jose-jwt + + + org.bouncycastle + bcprov-ext-jdk15on + 1.70 + + + org.bouncycastle + bcpkix-jdk15on + 1.70 + + + org.bouncycastle + bcprov-jdk15on + 1.70 + + + org.immutables + value + provided + + + org.immutables + builder + provided + + + com.fasterxml.jackson.core + jackson-databind + provided + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + provided + + + javax.inject + javax.inject + provided + + + com.fasterxml.jackson.datatype + jackson-datatype-guava + provided + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + provided + + + + + javax.xml.bind + jaxb-api + provided + + + com.sun.xml.bind + jaxb-impl + provided + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + ch.qos.logback + logback-classic + test + + + + + + + org.eclipse.sisu + sisu-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + ${java.io.tmpdir} + + + + + + diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProvider.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProvider.java new file mode 100644 index 0000000000..a46e8e0d1e --- /dev/null +++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProvider.java @@ -0,0 +1,198 @@ +package com.walmartlabs.concord.github.appinstallation; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.walmartlabs.concord.common.ExternalAuthToken; +import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig; +import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException; +import org.immutables.value.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.time.Duration; +import java.util.Date; +import java.util.function.BiFunction; + +public class AccessTokenProvider { + + private static final Logger log = LoggerFactory.getLogger(AccessTokenProvider.class); + + private final HttpClient httpClient; + private final Duration httpTimeout; + private final ObjectMapper objectMapper; + + public AccessTokenProvider(GitHubAppInstallationConfig cfg, ObjectMapper objectMapper) { + this.httpTimeout = cfg.getHttpClientTimeout(); + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(httpTimeout) + .build(); + } + + public AccessTokenProvider(GitHubAppInstallationConfig cfg, + ObjectMapper objectMapper, + HttpClient httpClient) { + this.httpTimeout = cfg.getHttpClientTimeout(); + this.objectMapper = objectMapper; + this.httpClient = httpClient; + } + + ExternalAuthToken getRepoInstallationToken(GitHubAppAuthConfig app, String orgRepo) throws GitHubAppException { + try { + var jwt = generateJWT(app); + var accessTokenUrl = getAccessTokenUrl(app.apiUrl(), orgRepo, jwt); + return ExternalAuthToken.StaticToken.builder() + .from(createAccessToken(accessTokenUrl, jwt)) + .username(app.username().orElse(null)) + .build(); + } catch (JOSEException e) { + throw new GitHubAppException("Error generating JWT for app: " + app.clientId()); + } + } + + private String getAccessTokenUrl(String apiBaseUrl, String installationRepo, String jwt) throws GitHubAppException { + var req = HttpRequest.newBuilder().GET() + .uri(URI.create(apiBaseUrl + "/repos/" + installationRepo + "/installation")) + .header("Authorization", "Bearer " + jwt) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .timeout(httpTimeout) + .build(); + + var appInstallation = sendRequest(req, 200, AccessTokenProvider.GitHubAppInstallationResp.class, (code, body) -> { + if (code == 404) { + // not possible to discern between repo not found and app not installed for existing (private) repo + log.warn("getAccessTokenUrl ['{}'] -> not found", installationRepo); + return new GitHubAppException.NotFoundException("Repo not found or App installation not found for repo"); + } + + log.warn("getAccessTokenUrl ['{}'] -> error: {} : {}", installationRepo, code, body); + return new GitHubAppException("Unexpected error locating repo installation: " + code); + }); + + return appInstallation.accessTokensUrl(); + } + + private ExternalAuthToken createAccessToken(String accessTokenUrl, String jwt) { + var req = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.noBody()) + .uri(URI.create(accessTokenUrl)) + .header("Authorization", "Bearer " + jwt) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .timeout(httpTimeout) + .build(); + + return sendRequest(req, 201, ExternalAuthToken.class, (code, body) -> { + log.warn("createAccessToken ['{}'] -> error: {} : {}", accessTokenUrl, code, body); + + if (code == 404) { + // this would be pretty odd to hit, this means the url returned from the installation lookup is invalid + return new GitHubAppException.NotFoundException("App access token url not found"); + } + + return new GitHubAppException("Unexpected error creating app access token: " + code); + }); + } + + private static String generateJWT(GitHubAppAuthConfig auth) throws JOSEException { + var pk = auth.privateKey(); + var rsaJWK = JWK.parseFromPEMEncodedObjects(pk).toRSAKey(); + + // Create RSA-signer with the private key + var signer = new RSASSASigner(rsaJWK); + + // Prepare JWT with claims set + var claimsSet = new JWTClaimsSet.Builder() + .issueTime(new Date()) + .issuer(auth.clientId()) + // JWT expiration. GH requires less than 10 minutes + .expirationTime(new Date(new Date().getTime() + Duration.ofMinutes(10).toMillis())) + .build(); + + var signedJWT = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(rsaJWK.getKeyID()) + .build(), + claimsSet); + + // Compute the RSA signature + signedJWT.sign(signer); + + // To serialize to compact form, produces something like + return signedJWT.serialize(); + } + + private T sendRequest(HttpRequest httpRequest, int expectedCode, Class clazz, BiFunction exFun) throws GitHubAppException { + try { + var resp = httpClient.send(httpRequest, BodyHandlers.ofInputStream()); + if (resp.statusCode() != expectedCode) { + throw exFun.apply(resp.statusCode(), readBody(resp)); + } + return objectMapper.readValue(resp.body(), clazz); + } catch (IOException e) { + throw new GitHubAppException("Error sending request", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + throw new IllegalStateException("Unexpected error sending HTTP request"); + } + + private static String readBody(HttpResponse resp) throws IOException { + try (var is = resp.body()) { + return new String(is.readAllBytes()); + } + } + + @Value.Immutable + @Value.Style(jdkOnly = true) + @JsonDeserialize(as = ImmutableGitHubAppInstallationResp.class) + @JsonIgnoreProperties(ignoreUnknown = true) + public interface GitHubAppInstallationResp { + + /* + This is all we **need**, even though there's other attributes. Some may differ + between GitHub "cloud" and GitHub Enterprise/private. So, be care if/when adding more. + */ + @JsonProperty("access_tokens_url") + String accessTokensUrl(); + + } +} diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java new file mode 100644 index 0000000000..ea5af01f1c --- /dev/null +++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java @@ -0,0 +1,66 @@ +package com.walmartlabs.concord.github.appinstallation; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import org.immutables.value.Value; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.net.URI; +import java.util.Objects; + +@Value.Immutable +@Value.Style(jdkOnly = true, redactedMask = "**redacted**") +interface CacheKey { + + URI repoUri(); + + @Nullable + @Value.Redacted + byte[] binaryDataSecret(); + + @Value.Default + default int weight() { + var weight = 1; + + if (binaryDataSecret() != null) { + var data = Objects.requireNonNull(binaryDataSecret()); + weight += 1; + weight += data.length / 1024; + } + + return weight; + } + + static CacheKey from(URI repoUri) { + return ImmutableCacheKey.builder() + .repoUri(repoUri) + .build(); + } + + static CacheKey from(URI repoUri, @Nonnull byte[] secret) { + return ImmutableCacheKey.builder() + .repoUri(repoUri) + .binaryDataSecret(secret) + .build(); + } + +} diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfig.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfig.java new file mode 100644 index 0000000000..d91260c3cd --- /dev/null +++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfig.java @@ -0,0 +1,53 @@ +package com.walmartlabs.concord.github.appinstallation; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import org.immutables.value.Value; + +@Value.Immutable +@Value.Style(jdkOnly = true) +@JsonDeserialize(as = ImmutableGitHubAppAuthConfig.class) +public interface GitHubAppAuthConfig extends MappingAuthConfig { + + @Value.Default + default String apiUrl() { + return "https://api.github.com"; + } + + String clientId(); + + String privateKey(); + + @Value.Check + default void checkUrlPattern() { + // sanity check url pattern before this object gets too far out there + if (!urlPattern().toString().contains("?")) { + throw new IllegalArgumentException("The url pattern must contain the ? named group"); + } + } + + static ImmutableGitHubAppAuthConfig.Builder builder() { + return ImmutableGitHubAppAuthConfig.builder(); + } + +} diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java new file mode 100644 index 0000000000..cf19d152be --- /dev/null +++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java @@ -0,0 +1,206 @@ +package com.walmartlabs.concord.github.appinstallation; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.cache.Weigher; +import com.google.common.util.concurrent.UncheckedExecutionException; +import com.walmartlabs.concord.common.AuthTokenProvider; +import com.walmartlabs.concord.common.ExternalAuthToken; +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import com.walmartlabs.concord.common.secret.BinaryDataSecret; +import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig; +import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException; +import com.walmartlabs.concord.github.appinstallation.exception.RepoExtractionException; +import com.walmartlabs.concord.sdk.Secret; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.inject.Inject; +import java.net.URI; +import java.util.Optional; +import java.util.concurrent.ExecutionException; + +public class GitHubAppInstallation implements AuthTokenProvider { + + private static final Logger log = LoggerFactory.getLogger(com.walmartlabs.concord.github.appinstallation.GitHubAppInstallation.class); + + private final GitHubAppInstallationConfig cfg; + private final AccessTokenProvider tokenProvider; + private final ObjectMapper objectMapper; + + private final LoadingCache> cache; + + @Inject + public GitHubAppInstallation(GitHubAppInstallationConfig cfg, ObjectMapper objectMapper) { + this.cfg = cfg; + this.objectMapper = objectMapper; + this.tokenProvider = new AccessTokenProvider(cfg, objectMapper); + + this.cache = CacheBuilder.newBuilder() + .expireAfterWrite(cfg.getSystemAuthCacheDuration()) + .maximumWeight(cfg.getSystemAuthCacheMaxWeight()) + .weigher((Weigher>) (key, value) -> key.weight()) + .build(new CacheLoader<>() { + @Override + public @Nonnull Optional load(@Nonnull CacheKey key) { + return fetchToken(key.repoUri(), key.binaryDataSecret()); + } + }); + } + + @Override + public boolean supports(URI repo, @Nullable Secret secret) { + return Utils.validateSecret(secret, objectMapper) || systemSupports(repo); + } + + private CacheKey createKey(URI repoUri, @Nullable Secret secret) { + if (secret == null) { + return CacheKey.from(repoUri); + } + + if (secret instanceof BinaryDataSecret bds) { + return CacheKey.from(repoUri, bds.getData()); + } + + return null; + } + + @Override + public Optional getToken(URI repo, @Nullable Secret secret) { + var cacheKey = createKey(repo, secret); + + if (cacheKey == null) { + return Optional.empty(); + } + + try { + var activeToken = cache.get(cacheKey); + + return activeToken.map(t -> refreshBeforeExpire(t, cacheKey)); + } catch (ExecutionException e) { + throw new GitHubAppException("Error retrieving access token for repo: " + repo, e); + } catch (UncheckedExecutionException e) { + // unwrap from guava + if (e.getCause() instanceof GitHubAppException repoEx) { + throw repoEx; + } + + log.warn("getAccessToken ['{}'] -> error: {}", repo, e.getMessage()); + + throw new GitHubAppException("Unexpected error retrieving access token for repo: " + repo); + } + } + + public long cacheSize() { + return cache.size(); + } + + private boolean systemSupports(URI repoUri) { + return cfg.getAuthConfigs().stream().anyMatch(auth -> auth.canHandle(repoUri)); + } + + private Optional fetchToken(URI repo, @Nullable byte[] secret) { + if (secret != null) { + return Optional.ofNullable(fromBinaryData(repo, secret)); + } + + // no secret, see if system config has something for this repo + return cfg.getAuthConfigs().stream() + .filter(auth -> auth.canHandle(repo)) + .findFirst() + .map(auth -> { + if (auth instanceof MappingAuthConfig.OauthAuthConfig tokenAuth) { + return GitHubInstallationToken.builder() + .token(tokenAuth.token()) + .username(tokenAuth.username().orElse(null)) + .build(); + } + + if (auth instanceof GitHubAppAuthConfig app) { + return getTokenFromAppInstall(app, repo); + } + + throw new IllegalArgumentException("Unsupported GitAuth type for repo: " + repo); + }); + } + + /** + * Cache may return a token that's close to expiring. If it's too close, + * invalidate and get a new one. If it's just a little close, refresh the + * cache in the background and return the still-active token. + */ + private ExternalAuthToken refreshBeforeExpire(@Nonnull ExternalAuthToken token, CacheKey cacheKey) { + if (token.secondsUntilExpiration() < 10) { + // not enough time to be useful. get a new token right now + cache.invalidate(cacheKey); + try { + return cache.get(cacheKey).orElse(null); + } catch (ExecutionException e) { + throw new GitHubAppException("Error retrieving access token for repo: " + cacheKey.repoUri(), e); + } + } + + // refresh cache if the token is expiring soon, doesn't affect current token + if (token.secondsUntilExpiration() < 300) { + cache.refresh(cacheKey); + } + + return token; + } + + private ExternalAuthToken fromBinaryData(URI repo, byte[] data) { + var appInfo = Utils.parseAppInstallation(data, objectMapper); + if (appInfo.isPresent()) { + // great, it's apparently a valid app installation config + return getTokenFromAppInstall(appInfo.get(), repo); + } + + // hopefully it's just a token a plaintext token + return GitHubInstallationToken.builder() + .token(new String(data).trim()) + .build(); + } + + private ExternalAuthToken getTokenFromAppInstall(GitHubAppAuthConfig app, URI repo) { + log.info("getTokenFromAppInstall ['{}', '{}']", app.apiUrl(), repo); + + try { + var ownerAndRepo = Utils.extractOwnerAndRepo(app, repo); + return accessTokenProvider().getRepoInstallationToken(app, ownerAndRepo); + } catch (RepoExtractionException | GitHubAppException e) { + var msg = e.getMessage(); + log.warn("Error retrieving GitHub access token: {}", msg); + } + + return null; + } + + AccessTokenProvider accessTokenProvider() { + return tokenProvider; + } + +} diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubInstallationToken.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubInstallationToken.java new file mode 100644 index 0000000000..0833bd7fc5 --- /dev/null +++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubInstallationToken.java @@ -0,0 +1,33 @@ +package com.walmartlabs.concord.github.appinstallation; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.walmartlabs.concord.common.ExternalAuthToken; +import org.immutables.value.Value; + +@Value.Immutable +@Value.Style(jdkOnly = true) +public interface GitHubInstallationToken extends ExternalAuthToken { + + static ImmutableGitHubInstallationToken.Builder builder() { + return ImmutableGitHubInstallationToken.builder(); + } +} diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java new file mode 100644 index 0000000000..7274d1f447 --- /dev/null +++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java @@ -0,0 +1,149 @@ +package com.walmartlabs.concord.github.appinstallation; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.walmartlabs.concord.common.secret.BinaryDataSecret; +import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException; +import com.walmartlabs.concord.github.appinstallation.exception.RepoExtractionException; +import com.walmartlabs.concord.sdk.Secret; + +import java.net.URI; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; + +public class Utils { + + /** + * Validates given secret is usable enough to attempt a remote lookup. Not + * guaranteed to actually work, just a sanity check useful to avoid attempting + * API calls with something that will definitely not work. + */ + static boolean validateSecret(Secret secret, ObjectMapper mapper) { + // secret must be one of: + // * JSON-formatted GitHub app installation details: clientId, privateKey, urlPattern + // * single-line, plaintext access token + + if (secret == null) { + return false; + } + + if (!(secret instanceof BinaryDataSecret bds)) { + // this class is not the place for handling key pairs or username/password + return false; + } + + var base = parseRawAppInstallation(bds.getData(), mapper); + if (base == null) { + // It's not JSON, may be an oauth token + return isPrintableAscii(bds.getData()); + } else if (base.isEmpty()) { + // Doesn't match something we can parse + return false; + } + + // App installation config format is either valid or not + return parseAppInstallation(bds.getData(), mapper).isPresent(); + } + + private static boolean isPrintableAscii(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + return false; + } + + for (byte b : bytes) { + // Cast byte to int to avoid issues with negative byte values + int asciiValue = b & 0xFF; // Use bitwise AND to get unsigned value + + if (asciiValue < 32 || asciiValue > 126) { + return false; + } + } + return true; + } + + static Map parseRawAppInstallation(byte[] bds, ObjectMapper mapper) { + try { // find out if it's at least valid JSON. + var base = mapper.readValue(bds, Map.class); + if (base.containsKey("githubAppInstallation")) { + return base; + } else { + // it's JSON, but not in our format + return Map.of(); + } + } catch (Exception e) { + // invalid JSON, may be a plaintext token + return null; + } + } + + static Optional parseAppInstallation(byte[] bds, ObjectMapper mapper) { + Map base = parseRawAppInstallation(bds, mapper); + + if (base == null || !base.containsKey("githubAppInstallation")) { + // it's either not JSON or not in our format + return Optional.empty(); + } + + try { // great, now convert it to the expected structure + return Optional.of(mapper.convertValue(base.get("githubAppInstallation"), GitHubAppAuthConfig.class)); + } catch (IllegalArgumentException e) { + // doesn't match the expected structure + throw new GitHubAppException("Invalid app installation definition.", e); + } + } + + static String extractOwnerAndRepo(GitHubAppAuthConfig auth, URI repo) throws RepoExtractionException { + var port = (repo.getPort() == -1 ? "" : (":" + repo.getPort())); + var path = (repo.getPath() == null ? "" : repo.getPath()); + var repoHostPortAndPath = repo.getHost() + port + path; + + var match = auth.urlPattern().matcher(repoHostPortAndPath); + + if (!match.matches()) { + // at this point, this should only fail if the urlPattern is not + // constructed correctly. We wouldn't get there if the pattern didn't + // match the repo in the first place. + throw new RepoExtractionException("Failed to parse owner and repository from path: " + repo.getPath()); + } + + var baseUrl = match.group("baseUrl"); + var relevantPath = repo.toString().replaceAll("^.*" + baseUrl + "/?", "") + .replaceAll(repo.getQuery() != null ? "\\?" + repo.getQuery() : "", "") + .replaceFirst("\\.git$", ""); + + // parse out the owner/repo from the path + var pathParts = Arrays.stream(relevantPath.split("/")) + .filter(e -> !e.isBlank()) + .limit(2) + .toList(); + + if (pathParts.size() != 2) { + throw new RepoExtractionException("Failed to parse owner and repository from path: " + repo.getPath()); + } + + return pathParts.get(0) + "/" + pathParts.get(1); + } + + private Utils() {} + +} diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/cfg/GitHubAppInstallationConfig.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/cfg/GitHubAppInstallationConfig.java new file mode 100644 index 0000000000..31140f693c --- /dev/null +++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/cfg/GitHubAppInstallationConfig.java @@ -0,0 +1,175 @@ +package com.walmartlabs.concord.github.appinstallation.cfg; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.typesafe.config.Config; +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import com.walmartlabs.concord.github.appinstallation.GitHubAppAuthConfig; +import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException; +import org.immutables.value.Value; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.List; +import java.util.Optional; + +@Value.Immutable +@Value.Style(jdkOnly = true) +public interface GitHubAppInstallationConfig { + + List getAuthConfigs(); + + @Value.Default + default Duration getSystemAuthCacheDuration() { + return Duration.ofMinutes(50); + } + + @Value.Default + default Duration getHttpClientTimeout() { + return Duration.ofSeconds(30); + } + + /** + * Weight is roughly calculated in kilobytes. Any cached item will have a + * minimum weight of 1. While further weight calculations are based on size + * of the given secret, if any. + *

+ * The default of 10,240 (~10MB) can hold approximately: + *

    + *
  • 10,000 tokens with no secret
  • + *
  • 5,000 tokens with string or credentials secret
  • + *
  • 3,500 tokens with app private key secret
  • + *
+ */ + @Value.Default + default long getSystemAuthCacheMaxWeight() { + return 1024 * 10L; + } + + static ImmutableGitHubAppInstallationConfig.Builder builder() { + return ImmutableGitHubAppInstallationConfig.builder(); + } + + static GitHubAppInstallationConfig fromConfig(Config config) { + var auths = config.getConfigList("auth").stream() + .map(GitHubAppInstallationConfig::toGitAuth) + .toList(); + + var builder = builder(); + + if (config.hasPath("httpClientTimeout")) { + builder.httpClientTimeout(config.getDuration("httpClientTimeout")); + } + + if (config.hasPath("systemAuthCacheDuration")) { + builder.systemAuthCacheDuration(config.getDuration("systemAuthCacheDuration")); + } + + if (config.hasPath("systemAuthCacheMaxWeight")) { + builder.systemAuthCacheMaxWeight(config.getInt("systemAuthCacheMaxWeight")); + } + + return builder + .authConfigs(auths) + .build(); + } + + enum AuthSource { + OAUTH_TOKEN, + GITHUB_APP_INSTALLATION, + } + + interface AuthConfig { + MappingAuthConfig toGitAuth(); + } + + static MappingAuthConfig toGitAuth(com.typesafe.config.Config auth) { + var a = switch (AuthSource.valueOf(auth.getString("type").toUpperCase())) { + case OAUTH_TOKEN -> OauthConfig.from(auth); + case GITHUB_APP_INSTALLATION -> AppInstallationConfig.from(auth); + }; + return a.toGitAuth(); + } + + record OauthConfig(String urlPattern, String username, String token) implements AuthConfig { + + static OauthConfig from(com.typesafe.config.Config cfg) { + var username = Optional.ofNullable(cfg.hasPath("username") ? cfg.getString("username") : null) + .filter(s -> !s.isBlank()) + .orElse(null); + + return new OauthConfig( + cfg.getString("urlPattern"), + username, + cfg.getString("token") + ); + } + + @Override + public MappingAuthConfig toGitAuth() { + return MappingAuthConfig.OauthAuthConfig.builder() + .token(this.token()) + .username(Optional.ofNullable(this.username())) + .urlPattern(MappingAuthConfig.assertBaseUrlPattern(this.urlPattern())) + .build(); + } + } + + record AppInstallationConfig(String urlPattern, String username, String apiUrl, String clientId, String privateKey) implements AuthConfig { + + static AppInstallationConfig from(com.typesafe.config.Config cfg) { + var username = Optional.ofNullable(cfg.hasPath("username") ? cfg.getString("username") : null) + .filter(s -> !s.isBlank()) + .orElse("x-access-token"); + + var apiUrl = Optional.ofNullable(cfg.hasPath("apiUrl") ? cfg.getString("apiUrl") : null) + .filter(s -> !s.isBlank()) + .orElse("https://api.github.com"); + + return new AppInstallationConfig( + cfg.getString("urlPattern"), + username, + apiUrl, + cfg.getString("clientId"), + cfg.getString("privateKey") + ); + } + + @Override + public MappingAuthConfig toGitAuth() { + try { + var pkData = Files.readString(Paths.get(this.privateKey())); + + return GitHubAppAuthConfig.builder() + .urlPattern(MappingAuthConfig.assertBaseUrlPattern(this.urlPattern())) + .username(this.username()) + .clientId(this.clientId()) + .privateKey(pkData) + .apiUrl(this.apiUrl()) + .build(); + } catch (IOException e) { + throw new GitHubAppException("Error initializing Git App Installation auth", e); + } + } + } +} diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/GitHubAppException.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/GitHubAppException.java new file mode 100644 index 0000000000..f883cfd06e --- /dev/null +++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/GitHubAppException.java @@ -0,0 +1,41 @@ +package com.walmartlabs.concord.github.appinstallation.exception; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +public class GitHubAppException extends RuntimeException { + public GitHubAppException(String message) { + super(message); + } + + public GitHubAppException(String message, Throwable cause) { + super(message, cause); + } + + public static class NotFoundException extends GitHubAppException { + public NotFoundException(String message) { + super(message); + } + + public NotFoundException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/RepoExtractionException.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/RepoExtractionException.java new file mode 100644 index 0000000000..b004a6af94 --- /dev/null +++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/RepoExtractionException.java @@ -0,0 +1,28 @@ +package com.walmartlabs.concord.github.appinstallation.exception; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +public class RepoExtractionException extends IllegalArgumentException { + + public RepoExtractionException(String s) { + super(s); + } +} diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProviderTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProviderTest.java new file mode 100644 index 0000000000..46042ad5ed --- /dev/null +++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProviderTest.java @@ -0,0 +1,148 @@ +package com.walmartlabs.concord.github.appinstallation; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig; +import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.http.HttpClient; +import java.net.http.HttpResponse; +import java.util.List; + +import static com.walmartlabs.concord.github.appinstallation.TestConstants.PRIVATE_KEY_TEXT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class AccessTokenProviderTest { + + @Mock + HttpClient httpClient; + + @Mock + HttpResponse tokenUrlResponse; + + @Mock + HttpResponse accessTokenResponse; + + private static final GitHubAppAuthConfig auth = GitHubAppAuthConfig.builder() + .urlPattern(MappingAuthConfig.assertBaseUrlPattern("(?github.local)/")) + .privateKey(PRIVATE_KEY_TEXT) + .clientId("123") + .build(); + + private static final GitHubAppInstallationConfig CFG = GitHubAppInstallationConfig.builder() + .authConfigs(List.of(auth)) + .build(); + @Test + void test() throws Exception { + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))) + .thenReturn(tokenUrlResponse, accessTokenResponse); + + when(tokenUrlResponse.statusCode()).thenReturn(200); + when(tokenUrlResponse.body()).thenReturn(asInputStream(ACCESS_TOKEN_URL_RESPONSE)); + + when(accessTokenResponse.statusCode()).thenReturn(201); + when(accessTokenResponse.body()).thenReturn(asInputStream(ACCESS_TOKEN_RESPONSE)); + + var provider = new AccessTokenProvider(CFG, TestConstants.MAPPPER, httpClient); + + // -- + + var result = provider.getRepoInstallationToken(auth, "owner/repo"); + + // -- + + assertNotNull(result); + assertEquals("mock-token", result.token()); + assertTrue(result.secondsUntilExpiration() > 300); + } + + @Test + void testAppNotInstalled() throws Exception { + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))) + .thenReturn(tokenUrlResponse); + + when(tokenUrlResponse.statusCode()).thenReturn(404); + when(tokenUrlResponse.body()).thenReturn(asInputStream("App is not installed on repo")); + + var provider = new AccessTokenProvider(CFG, TestConstants.MAPPPER, httpClient); + + // -- + + var ex = assertThrows(GitHubAppException.NotFoundException.class, + () -> provider.getRepoInstallationToken(auth, "owenr/repo")); + + // -- + + assertTrue(ex.getMessage().contains("Repo not found or App installation not found for repo")); + } + + @Test + void testErrorCreatingToken() throws Exception { + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))) + .thenReturn(tokenUrlResponse, accessTokenResponse); + + when(tokenUrlResponse.statusCode()).thenReturn(200); + when(tokenUrlResponse.body()).thenReturn(asInputStream(ACCESS_TOKEN_URL_RESPONSE)); + + when(accessTokenResponse.statusCode()).thenReturn(500); + when(accessTokenResponse.body()).thenReturn(asInputStream("server error")); + + var provider = new AccessTokenProvider(CFG, TestConstants.MAPPPER, httpClient); + + // -- + + var ex = assertThrows(GitHubAppException.class, + () -> provider.getRepoInstallationToken(auth, "owenr/repo")); + + // -- + + assertTrue(ex.getMessage().contains("Unexpected error creating app access token: 500")); + } + + private static final String ACCESS_TOKEN_URL_RESPONSE = """ + { + "access_tokens_url": "https://github.local/access_tokens" + }"""; + + private static final String ACCESS_TOKEN_RESPONSE = """ + { + "token": "mock-token", + "expires_at": "2099-12-31T23:59:59Z" + }"""; + + private static InputStream asInputStream(String s) { + return new ByteArrayInputStream(s.getBytes()); + } + +} diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigTest.java new file mode 100644 index 0000000000..3e515268cc --- /dev/null +++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigTest.java @@ -0,0 +1,42 @@ +package com.walmartlabs.concord.github.appinstallation; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GitHubAppAuthConfigTest { + + @Test + void testUrlPatternMissingNamedGroup() { + var ex = Assertions.assertThrows(Exception.class, () -> GitHubAppAuthConfig.builder() + .urlPattern(MappingAuthConfig.assertBaseUrlPattern(".*")) // needs to have baseUrl + .clientId("mock-client-id") + .privateKey("/not/used/in/test") + .apiUrl("https://api.github.com") + .build()); + + assertTrue(ex.getMessage().contains("The url pattern must contain the ? named group")); + } +} diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallationTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallationTest.java new file mode 100644 index 0000000000..0680d560dc --- /dev/null +++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallationTest.java @@ -0,0 +1,235 @@ +package com.walmartlabs.concord.github.appinstallation; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.walmartlabs.concord.common.ExternalAuthToken; +import com.walmartlabs.concord.common.ObjectMapperProvider; +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import com.walmartlabs.concord.common.secret.BinaryDataSecret; +import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig; +import com.walmartlabs.concord.sdk.Secret; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.List; + +import static com.walmartlabs.concord.github.appinstallation.TestConstants.APP_INSTALL_CONTENT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GitHubAppInstallationTest { + + private static final ObjectMapper MAPPER = new ObjectMapperProvider().get(); + + @Mock + AccessTokenProvider accessTokenProvider; + + private static final URI VALID_APP_AUTH_URI_01 = URI.create("https://github.local/owner/repo-1.git"); + private static final URI VALID_APP_AUTH_URI_02 = URI.create("https://github.local/owner/repo-2.git"); + private static final URI VALID_STATIC_AUTH_URI_01 = URI.create("https://staticgithub.local/owner/repo-1.git"); + + private static final GitHubAppAuthConfig auth = GitHubAppAuthConfig.builder() + .clientId("123") + .urlPattern(MappingAuthConfig.assertBaseUrlPattern("(?github.local)/")) + .privateKey("/does/not/exist") + .build(); + private static final MappingAuthConfig.OauthAuthConfig staticKeyAuth = MappingAuthConfig.OauthAuthConfig.builder() + .urlPattern(MappingAuthConfig.assertBaseUrlPattern("(?staticgithub.local)/")) + .token("static-token") + .build(); + private final GitHubAppInstallationConfig CFG = GitHubAppInstallationConfig.builder() + .authConfigs(List.of(staticKeyAuth, auth)) + .build(); + private static final ExternalAuthToken TOKEN_1HR = ExternalAuthToken.SimpleToken.builder() + .token("mock-installation-token") + .expiresAt(OffsetDateTime.now().plusHours(1)) + .build(); + private static final ExternalAuthToken TOKEN_200S = ExternalAuthToken.SimpleToken.builder() + .token("mock-200-seconds-expiration-token") + .expiresAt(OffsetDateTime.now().plusSeconds(200)) + .build(); + private static final ExternalAuthToken TOKEN_9S = ExternalAuthToken.SimpleToken.builder() + .token("mock-9-seconds-expiration-token") + .expiresAt(OffsetDateTime.now().plusSeconds(9)) + .build(); + private static final Secret MOCK_STATIC_SECRET = new BinaryDataSecret(staticKeyAuth.token().getBytes(StandardCharsets.UTF_8)); + + private static final Secret MOCK_APP_INSTALL_SECRET = new BinaryDataSecret(APP_INSTALL_CONTENT.getBytes(StandardCharsets.UTF_8)); + + @Test + void testCache() { + // -- prepare 0 + var app = new TestApp(CFG, MAPPER, accessTokenProvider); + + // -- validate 0 + // empty when nothing was ever looked up + assertEquals(0, app.cacheSize()); + + // -- prepare 1 + when(accessTokenProvider.getRepoInstallationToken(any(), any())) + .thenReturn(TOKEN_1HR); + + // -- execute 1 + var tokenResp = app.getToken(VALID_APP_AUTH_URI_01, null); + + // -- verify 1 + assertTrue(tokenResp.isPresent()); + assertEquals(TOKEN_1HR, tokenResp.get()); + assertEquals(1, app.cacheSize()); + verify(accessTokenProvider, times(1)) + .getRepoInstallationToken(any(), any()); + + // -- execute 2 + // should hit cache + app.getToken(VALID_APP_AUTH_URI_01, null); + app.getToken(VALID_APP_AUTH_URI_01, null); + + // -- verify 2 + verify(accessTokenProvider, times(1)) + .getRepoInstallationToken(any(), any()); + + // -- execute 3 + // Different repo, will retrieve first and hit cache second + app.getToken(VALID_APP_AUTH_URI_02, null); + app.getToken(VALID_APP_AUTH_URI_02, null); + + // -- verify 3 + // cache now has 2 entries + verify(accessTokenProvider, times(2)) + .getRepoInstallationToken(any(), any()); + } + + @Test + void testRefreshBeforeExpire() { + // -- prepare 0 + when(accessTokenProvider.getRepoInstallationToken(any(), any())) + .thenReturn(TOKEN_200S, TOKEN_1HR); + var app = new TestApp(CFG, MAPPER, accessTokenProvider); + + // -- execute 1 + var tokenResp = app.getToken(VALID_APP_AUTH_URI_01, null); + + // -- verify 1 + // first is usable so we receive that token, new token is refreshed in background + assertTrue(tokenResp.isPresent()); + assertEquals(TOKEN_200S, tokenResp.get()); + assertEquals(1, app.cacheSize()); + // + verify(accessTokenProvider, times(2)) + .getRepoInstallationToken(any(), any()); + } + + @Test + void testForceRefreshBeforeExpire() { + // -- prepare 0 + when(accessTokenProvider.getRepoInstallationToken(any(), any())) + .thenReturn(TOKEN_9S, TOKEN_1HR); + var app = new TestApp(CFG, MAPPER, accessTokenProvider); + + // -- execute 1 + var tokenResp = app.getToken(VALID_APP_AUTH_URI_01, null); + + // -- verify 1 + assertTrue(tokenResp.isPresent()); + assertEquals(TOKEN_1HR, tokenResp.get()); + assertEquals(1, app.cacheSize()); + // first is usable refresh is forced we get the new token + verify(accessTokenProvider, times(2)) + .getRepoInstallationToken(any(), any()); + } + + @Test + void testFromStaticTokenTokenConfig() { + // -- prepare 0 + var app = new TestApp(CFG, MAPPER, accessTokenProvider); + + // -- execute 1 + var tokenResp = app.getToken(VALID_STATIC_AUTH_URI_01, null); + + // -- verify 1 + assertTrue(tokenResp.isPresent()); + assertEquals(staticKeyAuth.token(), tokenResp.get().token()); + assertEquals(1, app.cacheSize()); + // not an app installation, no lookup expected + verify(accessTokenProvider, times(0)) + .getRepoInstallationToken(any(), any()); + } + + @Test + void testFromStaticTokenSecret() { + // -- prepare 0 + var app = new TestApp(CFG, MAPPER, accessTokenProvider); + + // -- execute 1 + var tokenResp = app.getToken(VALID_APP_AUTH_URI_01, MOCK_STATIC_SECRET); + + // -- verify 1 + assertTrue(tokenResp.isPresent()); + assertEquals(staticKeyAuth.token(), tokenResp.get().token()); + assertEquals(1, app.cacheSize()); + // not an app installation, no lookup expected + verify(accessTokenProvider, times(0)) + .getRepoInstallationToken(any(), any()); + } + + @Test + void testFromAppInstallSecret() { + // -- prepare 0 + when(accessTokenProvider.getRepoInstallationToken(any(), any())) + .thenReturn(TOKEN_1HR); + var app = new TestApp(CFG, MAPPER, accessTokenProvider); + + // -- execute 1 + var tokenResp = app.getToken(VALID_APP_AUTH_URI_01, MOCK_APP_INSTALL_SECRET); + + // -- verify 1 + assertTrue(tokenResp.isPresent()); + assertEquals(TOKEN_1HR, tokenResp.get()); + assertEquals(1, app.cacheSize()); + verify(accessTokenProvider, times(1)) + .getRepoInstallationToken(any(), any()); + } + + private static class TestApp extends GitHubAppInstallation { + private final AccessTokenProvider tokenProvider; + + public TestApp(GitHubAppInstallationConfig cfg, ObjectMapper objectMapper, AccessTokenProvider tokenProvider) { + super(cfg, objectMapper); + this.tokenProvider = tokenProvider; + } + + @Override + AccessTokenProvider accessTokenProvider() { + return tokenProvider; + } + } +} diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/RepoExtractionTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/RepoExtractionTest.java new file mode 100644 index 0000000000..878ee53dd3 --- /dev/null +++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/RepoExtractionTest.java @@ -0,0 +1,118 @@ +package com.walmartlabs.concord.github.appinstallation; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.net.URI; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RepoExtractionTest { + + static Stream validPublicUris() { + return Stream.of( + "https://github.com/owner/repo.git", // typicalRepo + "https://github.com/owner/repo", // no trailing '.git', should still work + "https://github.com/owner/repo/", // ...same with trailing slash + // with query params. not very typical, but does work in this pattern matching + "https://github.com/owner/repo.git?hello=world", + "https://github.com/owner/repo?hello=world", + "https://github.com/owner/repo/?hello=world" + ).map(URI::create); + } + + static Stream invalidPublicUris() { + return Stream.of( + "https://github.com/owner", // no repo + "https://github.com/owner/" // no repo with slash + ).map(URI::create); + } + + static Stream validProxiedUris() { + return Stream.of( + "https://git.company.local/proxypath/owner/repo.git", // typicalRepo + "https://git.company.local/proxypath/owner/repo", // no trailing '.git', should still work + "https://git.company.local/proxypath/owner/repo/", // ...same with trailing slash + // with query params. not very typical, but does work in this pattern matching + "https://git.company.local/proxypath/owner/repo.git?hello=world", + "https://git.company.local/proxypath/owner/repo?hello=world", + "https://git.company.local/proxypath/owner/repo/?hello=world" + ).map(URI::create); + } + + static Stream invalidProxiedUris() { + return Stream.of( + "https://git.company.local/proxypath/owner", // no repo + "https://git.company.local/proxypath/owner/" // no repo with slash + ).map(URI::create); + } + + @ParameterizedTest + @MethodSource("validPublicUris") + void testValidPublicUris(URI repo) { + var ownerAndRepo = assertDoesNotThrow(() -> runExtract("(?github.com).*", repo)); + assertEquals("owner/repo", ownerAndRepo); + } + + @ParameterizedTest + @MethodSource("invalidPublicUris") + void testInvalidPublicUris(URI repo) { + var ex = assertThrows(IllegalArgumentException.class, + () -> runExtract("(?github.com).*", repo)); + assertTrue(ex.getMessage().contains("Failed to parse owner and repository from path")); + } + + @ParameterizedTest + @MethodSource("validProxiedUris") + void testValidProxiedUris(URI repo) { + var ownerAndRepo = assertDoesNotThrow(() -> runExtract("(?git.company.local/proxypath).*", repo)); + assertEquals("owner/repo", ownerAndRepo); + } + + @ParameterizedTest + @MethodSource("invalidProxiedUris") + void testInvalidProxiedUris(URI repo) { + var ex = assertThrows(IllegalArgumentException.class, + () -> runExtract("(?git.company.local/proxypath).*", repo)); + assertTrue(ex.getMessage().contains("Failed to parse owner and repository from path")); + } + + private static String runExtract(String pattern, URI repo) { + var auth = getAuth(pattern); + return Utils.extractOwnerAndRepo(auth, repo); + } + + private static GitHubAppAuthConfig getAuth(String urlPattern) { + return GitHubAppAuthConfig.builder() + .urlPattern(MappingAuthConfig.assertBaseUrlPattern(urlPattern)) + .privateKey("/not/used") + .clientId("1234") + .apiUrl("https://api.github.com") + .build(); + } +} diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/TestConstants.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/TestConstants.java new file mode 100644 index 0000000000..9d54dea56f --- /dev/null +++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/TestConstants.java @@ -0,0 +1,66 @@ +package com.walmartlabs.concord.github.appinstallation; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.walmartlabs.concord.common.ObjectMapperProvider; +import com.walmartlabs.concord.common.secret.BinaryDataSecret; +import com.walmartlabs.concord.sdk.Secret; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +public class TestConstants { + static final ObjectMapper MAPPPER = new ObjectMapperProvider().get(); + static final String APP_INSTALL_CONTENT = """ + { + "githubAppInstallation": { + "urlPattern": "(?github.local)/.*", + "clientId": "123", + "privateKey": "mock-key-data" + } + }"""; + static final Secret MOCK_APP_INSTALL_SECRET = new BinaryDataSecret(APP_INSTALL_CONTENT.getBytes(StandardCharsets.UTF_8)); + static final Secret MOCK_STATIC_TOKEN_SECRET = new BinaryDataSecret("mock-static-token".getBytes(StandardCharsets.UTF_8)); + + public static final String PRIVATE_KEY_TEXT = generatePrivateKey(); + + private static String generatePrivateKey() { + KeyPairGenerator kpg; + try { + kpg = KeyPairGenerator.getInstance("RSA"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Algorithm not found", e); + } + + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + Base64.Encoder encoder = Base64.getEncoder(); + + return "-----BEGIN PRIVATE KEY-----\n" + + encoder.encodeToString(kp.getPrivate().getEncoded()) + + "\n-----END PRIVATE KEY-----\n"; + } +} diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/UtilsTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/UtilsTest.java new file mode 100644 index 0000000000..62355c8c0c --- /dev/null +++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/UtilsTest.java @@ -0,0 +1,105 @@ +package com.walmartlabs.concord.github.appinstallation; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.walmartlabs.concord.common.secret.BinaryDataSecret; +import com.walmartlabs.concord.common.secret.UsernamePassword; +import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static com.walmartlabs.concord.github.appinstallation.TestConstants.APP_INSTALL_CONTENT; +import static com.walmartlabs.concord.github.appinstallation.TestConstants.MAPPPER; +import static com.walmartlabs.concord.github.appinstallation.TestConstants.MOCK_APP_INSTALL_SECRET; +import static com.walmartlabs.concord.github.appinstallation.TestConstants.MOCK_STATIC_TOKEN_SECRET; +import static com.walmartlabs.concord.github.appinstallation.Utils.parseAppInstallation; +import static com.walmartlabs.concord.github.appinstallation.Utils.parseRawAppInstallation; +import static com.walmartlabs.concord.github.appinstallation.Utils.validateSecret; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class UtilsTest { + + @Test + void testValidateSecret() { + assertTrue(validateSecret(MOCK_APP_INSTALL_SECRET, MAPPPER)); + assertTrue(validateSecret(MOCK_STATIC_TOKEN_SECRET, MAPPPER)); + assertFalse(validateSecret(null, MAPPPER)); + assertFalse(validateSecret(Mockito.mock(UsernamePassword.class), MAPPPER)); + assertFalse(validateSecret(new BinaryDataSecret(null), MAPPPER)); + assertFalse(validateSecret(new BinaryDataSecret(new byte[]{}), MAPPPER)); + assertFalse(validateSecret(new BinaryDataSecret("\nmytoken".getBytes()), MAPPPER)); + assertFalse(validateSecret(new BinaryDataSecret("{\"hello\":\"world\"}".getBytes()), MAPPPER)); + } + + @Test + void parseAppInstallation_ValidJson() { + var o = parseAppInstallation(APP_INSTALL_CONTENT.getBytes(), MAPPPER); + + assertTrue(o.isPresent()); + var result = o.get(); + assertEquals("123", result.clientId()); + } + + @Test + void parseAppInstallation_MissingElement() { + var missingClientId = """ + { + "githubAppInstallation": { + "urlPattern": "(?github.local)/.*", + "privateKey": "mock-key-data" + } + }"""; + var data = missingClientId.getBytes(); + var ex = assertThrows(GitHubAppException.class, () -> parseAppInstallation(data, MAPPPER)); + + assertTrue(ex.getMessage().contains("Invalid app installation definition")); + } + + @Test + void parseAppInstallation_OtherJson() { + var unexpectedJson = "{ \"valid\": \"but not usable here\"}"; + var result = parseAppInstallation(unexpectedJson.getBytes(), MAPPPER); + + assertFalse(result.isPresent()); + } + + @Test + void parseRawAppInstallation_NotJson() { + var unexpectedJson = "justText"; + var result = parseRawAppInstallation(unexpectedJson.getBytes(), MAPPPER); + + assertNull(result); + } + + @Test + void parseRawAppInstallation_OtherJson() { + var unexpectedJson = "{ \"valid\": \"but not usable here\"}"; + var result = parseRawAppInstallation(unexpectedJson.getBytes(), MAPPPER); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } +} diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/cfg/ConfigTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/cfg/ConfigTest.java new file mode 100644 index 0000000000..76b9b89429 --- /dev/null +++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/cfg/ConfigTest.java @@ -0,0 +1,98 @@ +package com.walmartlabs.concord.github.appinstallation.cfg; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.typesafe.config.ConfigFactory; +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import com.walmartlabs.concord.github.appinstallation.GitHubAppAuthConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import static com.walmartlabs.concord.github.appinstallation.TestConstants.PRIVATE_KEY_TEXT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class ConfigTest { + + @TempDir + private static Path workDir; + + @Test + void simpleConfig() throws Exception { + var pk = Files.writeString(workDir.resolve("pk.pem"), PRIVATE_KEY_TEXT); + var typesafeConfig = ConfigFactory.parseString(""" + { + "auth" = [ + { type = "GITHUB_APP_INSTALLATION", urlPattern = "(?github.local)", clientId = "123", privateKey = "{{PK_PATH}}" }, + { type = "OAUTH_TOKEN", urlPattern = "(?github.local)", token = "mock-token" } + ] + }""".replace("{{PK_PATH}}", pk.toString())); + var cfg = GitHubAppInstallationConfig.fromConfig(typesafeConfig); + assertNotNull(cfg); + assertEquals(10240, cfg.getSystemAuthCacheMaxWeight()); + assertEquals(Duration.ofSeconds(30), cfg.getHttpClientTimeout()); + assertEquals(Duration.ofMinutes(50), cfg.getSystemAuthCacheDuration()); + assertEquals(2, cfg.getAuthConfigs().size()); + + var appInstall = assertInstanceOf(GitHubAppAuthConfig.class, cfg.getAuthConfigs().get(0)); + assertEquals("x-access-token", appInstall.username().orElse(null)); + assertEquals("https://api.github.com", appInstall.apiUrl()); + + var oauth = assertInstanceOf(MappingAuthConfig.OauthAuthConfig.class, cfg.getAuthConfigs().get(1)); + assertFalse(oauth.username().isPresent()); + assertEquals("mock-token", oauth.token()); + } + + @Test + void overrideConfig() throws Exception { + var pk = Files.writeString(workDir.resolve("pk.pem"), PRIVATE_KEY_TEXT); + var typesafeConfig = ConfigFactory.parseString(""" + { + httpClientTimeout = "1 minute", + systemAuthCacheDuration = "1 minute", + systemAuthCacheMaxWeight = "10" + "auth" = [ + { type = "GITHUB_APP_INSTALLATION", urlPattern = "(?github.local)", username = "custom", apiUrl = "https://api.github.local", clientId = "123", privateKey = "{{PK_PATH}}" }, + { type = "OAUTH_TOKEN", urlPattern = "(?github.local)", token = "mock-token", username = "custom" } + ] + }""".replace("{{PK_PATH}}", pk.toString())); + var cfg = GitHubAppInstallationConfig.fromConfig(typesafeConfig); + assertNotNull(cfg); + assertEquals(10, cfg.getSystemAuthCacheMaxWeight()); + assertEquals(Duration.ofMinutes(1), cfg.getHttpClientTimeout()); + assertEquals(Duration.ofMinutes(1), cfg.getSystemAuthCacheDuration()); + assertEquals(2, cfg.getAuthConfigs().size()); + + var appInstall = assertInstanceOf(GitHubAppAuthConfig.class, cfg.getAuthConfigs().get(0)); + assertEquals("custom", appInstall.username().orElse(null)); + assertEquals("https://api.github.local", appInstall.apiUrl()); + + var oauth = assertInstanceOf(MappingAuthConfig.OauthAuthConfig.class, cfg.getAuthConfigs().get(1)); + assertEquals("custom", oauth.username().orElse(null)); + assertEquals("mock-token", oauth.token()); + } +} diff --git a/github-app-installation/src/test/resources/logback-test.xml b/github-app-installation/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..00003ef25f --- /dev/null +++ b/github-app-installation/src/test/resources/logback-test.xml @@ -0,0 +1,13 @@ + + + + %d{HH:mm:ss.SSS} [%thread] [%-5level] %logger{36} - %msg%n + + + + + + + + + diff --git a/it/common/src/main/java/com/walmartlabs/concord/it/common/ITUtils.java b/it/common/src/main/java/com/walmartlabs/concord/it/common/ITUtils.java index 2338ca35b8..0316088399 100644 --- a/it/common/src/main/java/com/walmartlabs/concord/it/common/ITUtils.java +++ b/it/common/src/main/java/com/walmartlabs/concord/it/common/ITUtils.java @@ -86,7 +86,7 @@ public static String createGitRepo(Path src) throws IOException, GitAPIException repo.add().addFilepattern(".").call(); repo.commit().setMessage("import").call(); - return tmpDir.toAbsolutePath().toString(); + return "file://" + tmpDir.toAbsolutePath().toString(); } public static String randomString() { diff --git a/it/console/src/test/resources/agent.conf b/it/console/src/test/resources/agent.conf index 7cb73c47fb..bd8fbbf815 100644 --- a/it/console/src/test/resources/agent.conf +++ b/it/console/src/test/resources/agent.conf @@ -4,6 +4,10 @@ concord-agent { logMaxDelay = "250 milliseconds" pollInterval = "250 milliseconds" + git { + allowedSchemes = [ "file", "https", "ssh", "classpath" ] + } + server { apiKey = "cTJxMnEycTI=" } diff --git a/it/console/src/test/resources/server.conf b/it/console/src/test/resources/server.conf index 72850cb0ec..cfd91d100e 100644 --- a/it/console/src/test/resources/server.conf +++ b/it/console/src/test/resources/server.conf @@ -13,6 +13,10 @@ concord-server { } } + git { + allowedSchemes = [ "file", "https", "ssh", "classpath" ] + } + secretStore { serverPassword = "aXRpdGl0" secretStoreSalt = "aXRpdGl0" diff --git a/it/runtime-v2/src/test/java/com/walmartlabs/concord/it/runtime/v2/ConcordConfiguration.java b/it/runtime-v2/src/test/java/com/walmartlabs/concord/it/runtime/v2/ConcordConfiguration.java index b20a535f4e..3e0edc4fb7 100644 --- a/it/runtime-v2/src/test/java/com/walmartlabs/concord/it/runtime/v2/ConcordConfiguration.java +++ b/it/runtime-v2/src/test/java/com/walmartlabs/concord/it/runtime/v2/ConcordConfiguration.java @@ -102,16 +102,11 @@ public static ConcordRule configure() { // TODO: move to testcontainers public static String getServerUrlForAgent(ConcordRule concord) { - switch (concord.mode()) { - case LOCAL: - return "http://localhost:8001"; - case REMOTE: - return System.getProperty("it.remote.baseUrl"); - case DOCKER: - return "http://server:8001"; - default: - throw new IllegalArgumentException("Unknown mode: " + concord.mode()); - } + return switch (concord.mode()) { + case LOCAL -> "http://localhost:8001"; + case REMOTE -> System.getProperty("it.remote.baseUrl"); + case DOCKER -> "http://server:8001"; + }; } private static Path writePrivateKey(Path targetFile) { diff --git a/it/runtime-v2/src/test/java/com/walmartlabs/concord/it/runtime/v2/GitHubTriggersV2IT.java b/it/runtime-v2/src/test/java/com/walmartlabs/concord/it/runtime/v2/GitHubTriggersV2IT.java index ee9bf989a4..3c892599d3 100644 --- a/it/runtime-v2/src/test/java/com/walmartlabs/concord/it/runtime/v2/GitHubTriggersV2IT.java +++ b/it/runtime-v2/src/test/java/com/walmartlabs/concord/it/runtime/v2/GitHubTriggersV2IT.java @@ -68,10 +68,6 @@ public void testFilterBySender() throws Exception { String orgXName = "orgX_" + randomString(); concord.organizations().create(orgXName); - Path repo = initRepo("triggers/github/repos/v2/defaultTrigger"); - String branch = "branch_" + randomString(); - createNewBranch(repo, branch, "triggers/github/repos/v2/defaultTriggerWithSender"); - // Project A // master branch + a default trigger String projectAName = "projectA_" + randomString(); @@ -79,12 +75,12 @@ public void testFilterBySender() throws Exception { Path projectARepo = initProjectAndRepo(orgXName, projectAName, repoAName, null, initRepo("triggers/github/repos/v2/defaultTrigger")); refreshRepo(orgXName, projectAName, repoAName); - // Project G + // Project B // accepts only specific commit authors - String projectGName = "projectG_" + randomString(); - String repoGName = "repoG_" + randomString(); - Path projectBRepo = initProjectAndRepo(orgXName, projectGName, repoGName, null, initRepo("triggers/github/repos/v2/defaultTriggerWithSender")); - refreshRepo(orgXName, projectGName, repoGName); + String projectBName = "projectG_" + randomString(); + String repoBName = "repoG_" + randomString(); + Path projectBRepo = initProjectAndRepo(orgXName, projectBName, repoBName, null, initRepo("triggers/github/repos/v2/defaultTriggerWithSender")); + refreshRepo(orgXName, projectBName, repoBName); // --- @@ -96,7 +92,7 @@ public void testFilterBySender() throws Exception { // A's triggers should be activated waitForAProcess(orgXName, projectAName, "github"); - expectNoProcesses(orgXName, projectGName, null); + expectNoProcesses(orgXName, projectBName, null); // --- @@ -114,7 +110,7 @@ public void testFilterBySender() throws Exception { "_USER_LDAP_DN", ""); // G's triggers should be activated - waitForAProcess(orgXName, projectGName, "github"); + waitForAProcess(orgXName, projectBName, "github"); // no A's are expected expectNoProcesses(orgXName, projectAName, now); @@ -216,7 +212,7 @@ private static Path initProjectAndRepo(String orgName, String projectName, Strin RepositoryEntry repo = new RepositoryEntry() .branch(repoBranch != null ? repoBranch : "master") - .url(bareRepo.toAbsolutePath().toString()); + .url("file://" + bareRepo.toAbsolutePath().toString()); projectsApi.createOrUpdateProject(orgName, new ProjectEntry() .name(projectName) diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/AbstractGitHubTriggersIT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/AbstractGitHubTriggersIT.java index a66024c96b..0c318a2a46 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/AbstractGitHubTriggersIT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/AbstractGitHubTriggersIT.java @@ -62,7 +62,7 @@ protected Path initProjectAndRepo(String orgName, String projectName, String rep RepositoryEntry repo = new RepositoryEntry() .branch(repoBranch != null ? repoBranch : "master") - .url(bareRepo.toAbsolutePath().toString()); + .url("file://" + bareRepo.toAbsolutePath().toString()); projectsApi.createOrUpdateProject(orgName, new ProjectEntry() .name(projectName) diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/AbstractOneOpsTriggerIT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/AbstractOneOpsTriggerIT.java index 828a4aa31e..79c397a4a3 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/AbstractOneOpsTriggerIT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/AbstractOneOpsTriggerIT.java @@ -101,7 +101,7 @@ protected Path initProjectAndRepo(String orgName, String projectName, String rep RepositoryEntry repo = new RepositoryEntry() .branch("master") - .url(bareRepo.toAbsolutePath().toString()); + .url("file://" + bareRepo.toAbsolutePath().toString()); projectsApi.createOrUpdateProject(orgName, new ProjectEntry() .name(projectName) diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/AbstractServerIT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/AbstractServerIT.java index b716787b1c..469c521bab 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/AbstractServerIT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/AbstractServerIT.java @@ -210,7 +210,7 @@ protected String createRepo(String resource) throws Exception { repo.commit().setMessage("import").call(); } - return tmpDir.toAbsolutePath().toString(); + return "file://" + tmpDir.toAbsolutePath().toString(); } protected static String env(String k, String def) { @@ -245,6 +245,25 @@ protected void withProject(String orgName, Consumer consumer) throws Exc } } + protected UserInfo addUser(String username, Set roles) throws ApiException { + var usersApi = new UsersApi(getApiClient()); + var user = usersApi.createOrUpdateUser(new CreateUserRequest().username(username) + .type(CreateUserRequest.TypeEnum.LOCAL)); + + if (!roles.isEmpty()) { + usersApi.updateUserRoles(username, new UpdateUserRolesRequest() + .roles(roles)); + } + + var apiKeysApi = new ApiKeysApi(getApiClient()); + var apiKeyResp = apiKeysApi.createUserApiKey(new CreateApiKeyRequest() + .userId(user.getId())); + + return new UserInfo(username, user.getId(), apiKeyResp.getKey()); + } + + protected record UserInfo(String username, UUID userId, String apiKey) { } + @FunctionalInterface public interface Consumer { void accept(T t) throws Exception; diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/CronIT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/CronIT.java index e55ef8cac0..f23b4bf044 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/CronIT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/CronIT.java @@ -49,7 +49,7 @@ public class CronIT extends AbstractServerIT { @Test public void testProfiles() throws Exception { - String gitUrl = initRepo("cronProfiles"); + String gitUrl = "file://" + initRepo("cronProfiles"); // --- @@ -118,7 +118,7 @@ public void testProfiles() throws Exception { @Test public void testRunAs() throws Exception { - String gitUrl = initRepo("cronRunAs"); + String gitUrl = "file://" + initRepo("cronRunAs"); // --- diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/ExternalImportsIT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/ExternalImportsIT.java index 1455ae1e57..07b09030f0 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/ExternalImportsIT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/ExternalImportsIT.java @@ -44,7 +44,7 @@ public class ExternalImportsIT extends AbstractServerIT { @Test public void testExternalImportWithForm() throws Exception { - String repoUrl = initRepo("externalImportWithForm"); + String repoUrl = "file://" + initRepo("externalImportWithForm"); // prepare the payload Path payloadDir = createPayload("externalImportMain", repoUrl); @@ -84,7 +84,7 @@ public void testExternalImportWithForm() throws Exception { @Test public void testExternalImportWithDefaults() throws Exception { - String repoUrl = initRepo("externalImport"); + String repoUrl = "file://" + initRepo("externalImport"); // prepare the payload Path payloadDir = createPayload("externalImportMain", repoUrl); @@ -114,7 +114,7 @@ public void testExternalImportWithDefaults() throws Exception { @Test public void testExternalImportWithPath() throws Exception { - String repoUrl = initRepo("externalImportWithDir"); + String repoUrl = "file://" + initRepo("externalImportWithDir"); // prepare the payload Path payloadDir = createPayload("externalImportMainWithPath", repoUrl); @@ -144,7 +144,7 @@ public void testExternalImportWithPath() throws Exception { @Test public void testExternalImportWithConfigurationInImport() throws Exception { - String repoUrl = initRepo("externalImportWithConfiguration"); + String repoUrl = "file://" + initRepo("externalImportWithConfiguration"); // prepare the payload Path payloadDir = createPayload("externalImportMain", repoUrl); @@ -176,7 +176,7 @@ public void testExternalImportWithConfigurationInImport() throws Exception { // concord.yml from import will use. @Test public void testExternalImportWithConcordDirReplace() throws Exception { - String repoUrl = initRepo("externalImport"); + String repoUrl = "file://" + initRepo("externalImport"); // prepare the payload Path payloadDir = createPayload("externalImportMainWithFlow", repoUrl); @@ -208,7 +208,7 @@ public void testExternalImportWithOnFailure() throws Exception { String repoUrl = initRepo("externalImportFailHandler"); // prepare the payload - Path payloadDir = createPayload("externalImportMainFailed", repoUrl); + Path payloadDir = createPayload("externalImportMainFailed", "file://" + repoUrl); byte[] payload = archive(payloadDir.toUri()); // start the process @@ -232,7 +232,7 @@ public void testExternalImportWithOnFailure() throws Exception { @Test public void testExternalImportWithForks() throws Exception { - String repoUrl = initRepo("externalImportWithForks"); + String repoUrl = "file://" + initRepo("externalImportWithForks"); // prepare the payload Path payloadDir = createPayload("externalImportMainWithForks", repoUrl); @@ -263,12 +263,12 @@ public void testExternalImportValidation() throws Exception { String importRepoUrl = initRepo("externalImport"); String userRepoUrl = initRepo("externalImportTriggerReference"); - replace(Paths.get(userRepoUrl, "concord.yml"), "{{gitUrl}}", importRepoUrl); + replace(Paths.get(userRepoUrl, "concord.yml"), "{{gitUrl}}", "file://" + importRepoUrl); commit(Paths.get(userRepoUrl).toFile()); // --- - assertExternalImportValidation(userRepoUrl); + assertExternalImportValidation("file://" + userRepoUrl); } @Test @@ -286,12 +286,12 @@ public void testExternalImportWithSymlink() throws Exception { }); String userRepoUrl = initRepo("externalImportTriggerReference"); - replace(Paths.get(userRepoUrl, "concord.yml"), "{{gitUrl}}", importRepoUrl); + replace(Paths.get(userRepoUrl, "concord.yml"), "{{gitUrl}}", "file://" + importRepoUrl); commit(Paths.get(userRepoUrl).toFile()); // --- - assertExternalImportValidation(userRepoUrl); + assertExternalImportValidation("file://" + userRepoUrl); } private void assertExternalImportValidation(String userRepoUrl) throws Exception { @@ -306,7 +306,7 @@ private void assertExternalImportValidation(String userRepoUrl) throws Exception projectsApi.createOrUpdateProject(orgName, new ProjectEntry() .name(projectName) .repositories(Collections.singletonMap(repoName, new RepositoryEntry() - .url(userRepoUrl) + .url("file://" + userRepoUrl) .branch("master")))); RepositoriesApi repositoriesApi = new RepositoriesApi(getApiClient()); @@ -318,7 +318,7 @@ private void assertExternalImportValidation(String userRepoUrl) throws Exception @Test public void testExternalImportWithExcludeFullDir() throws Exception { - String repoUrl = initRepo("externalImportWithDir"); + String repoUrl = "file://" + initRepo("externalImportWithDir"); // prepare the payload Path payloadDir = createPayload("externalImportMainWithExclude", repoUrl, "dir"); @@ -348,7 +348,7 @@ public void testExternalImportWithExcludeFullDir() throws Exception { @Test public void testExternalImportWithExcludeFileFromDir() throws Exception { - String repoUrl = initRepo("externalImportWithDir"); + String repoUrl = "file://" + initRepo("externalImportWithDir"); // prepare the payload Path payloadDir = createPayload("externalImportMainWithExclude", repoUrl, "dir/concord.yml"); @@ -381,7 +381,7 @@ public void testExternalImportWithExcludeFile() throws Exception { String repoUrl = initRepo("externalImportWithDir"); // prepare the payload - Path payloadDir = createPayload("externalImportMainWithExclude", repoUrl, "concord.yml"); + Path payloadDir = createPayload("externalImportMainWithExclude", "file://" + repoUrl, "concord.yml"); byte[] payload = archive(payloadDir.toUri()); // start the process @@ -409,7 +409,7 @@ public void testExternalImportWithExcludeFile() throws Exception { @Test public void testImportWithTriggers() throws Exception { String importRepoUrl = initRepo("testTrigger"); - String clientRepoUrl = initRepo("importATrigger", "concord.yml", "{{gitUrl}}", importRepoUrl); + String clientRepoUrl = initRepo("importATrigger", "concord.yml", "{{gitUrl}}", "file://" + importRepoUrl); String orgName = "org_" + randomString(); OrganizationsApi orgApi = new OrganizationsApi(getApiClient()); @@ -422,7 +422,7 @@ public void testImportWithTriggers() throws Exception { projectsApi.createOrUpdateProject(orgName, new ProjectEntry() .name(projectName) .repositories(Collections.singletonMap(repoName, new RepositoryEntry() - .url(clientRepoUrl) + .url("file://" + clientRepoUrl) .branch("master")))); // --- @@ -476,7 +476,7 @@ public void testImportWithTriggers() throws Exception { public void testDependencyMerging() throws Exception { String repoUrl = initRepo("externalImportWithDeps"); - Path payloadDir = createPayload("externalImportMainWithDeps", repoUrl, "concord.yml"); + Path payloadDir = createPayload("externalImportMainWithDeps", "file://" + repoUrl, "concord.yml"); byte[] payload = archive(payloadDir.toUri()); // --- @@ -498,7 +498,7 @@ public void testExternalImportState() throws Exception { String repoUrl = initRepo("externalImportWithDir"); // prepare the payload - Path payloadDir = createPayload("externalImportMainStateTest", repoUrl); + Path payloadDir = createPayload("externalImportMainStateTest", "file://" + repoUrl); byte[] payload = archive(payloadDir.toUri()); // start the process @@ -530,7 +530,7 @@ public void testGitImportWithCommitAsVersion() throws Exception { // prepare the payload Map replacements = new HashMap<>(); - replacements.put("{{gitUrl}}", repoUrl); + replacements.put("{{gitUrl}}", "file://" + repoUrl); replacements.put("{{version}}", commitId); Path payloadDir = createPayload("externalImportMainWithVersion", replacements); byte[] payload = archive(payloadDir.toUri()); diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/GeneralTriggerIT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/GeneralTriggerIT.java index 4b6899f912..e5757df0f3 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/GeneralTriggerIT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/GeneralTriggerIT.java @@ -45,7 +45,7 @@ public void testExclusive() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- @@ -100,7 +100,7 @@ public void testExclusiveFromConfiguration() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- @@ -156,7 +156,7 @@ public void testExclusiveWithTriggerOverride() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/GeneralTriggerV2IT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/GeneralTriggerV2IT.java index 6b77ec9c53..3c3f83bc8e 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/GeneralTriggerV2IT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/GeneralTriggerV2IT.java @@ -49,7 +49,7 @@ private void setup(String yamlPath) throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/GitHubNonOrgEventIt.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/GitHubNonOrgEventIt.java index b51ad533ad..77c4ee88f8 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/GitHubNonOrgEventIt.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/GitHubNonOrgEventIt.java @@ -52,7 +52,7 @@ public void test() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/GitHubTriggersV2IT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/GitHubTriggersV2IT.java index 3c98da4e33..776107e395 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/GitHubTriggersV2IT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/GitHubTriggersV2IT.java @@ -66,10 +66,6 @@ public void testFilterBySender() throws Exception { String orgXName = "orgX_" + randomString(); orgApi.createOrUpdateOrg(new OrganizationEntry().name(orgXName)); - Path repo = initRepo("githubTests/repos/v2/defaultTrigger"); - String branch = "branch_" + randomString(); - createNewBranch(repo, branch, "githubTests/repos/v2/defaultTriggerWithSender"); - // Project A // master branch + a default trigger String projectAName = "projectA_" + randomString(); @@ -81,7 +77,7 @@ public void testFilterBySender() throws Exception { // accepts only specific commit authors String projectGName = "projectG_" + randomString(); String repoGName = "repoG_" + randomString(); - Path projectBRepo = initProjectAndRepo(orgXName, projectGName, repoGName, null, initRepo("githubTests/repos/v2/defaultTriggerWithSender")); + Path projectGRepo = initProjectAndRepo(orgXName, projectGName, repoGName, null, initRepo("githubTests/repos/v2/defaultTriggerWithSender")); refreshRepo(orgXName, projectGName, repoGName); // --- @@ -106,7 +102,7 @@ public void testFilterBySender() throws Exception { // --- sendEvent("githubTests/events/direct_branch_push.json", "push", - "_FULL_REPO_NAME", toRepoName(projectBRepo), + "_FULL_REPO_NAME", toRepoName(projectGRepo), "_REF", "refs/heads/master", "_USER_NAME", "somecooldude", "_USER_LDAP_DN", ""); @@ -119,6 +115,18 @@ public void testFilterBySender() throws Exception { // --- + /* + unclear how this test is meant to function: + - launch process A (no filter on sender in definitions) with a sender who would be blocked if the filter sender definitions were used, but they aren't so?? + - wait for process A trigger to create a process + - expect project A to have no processes (???) + - launch process G (filter on sender in definitions) with a sender who is allowed through the sender filter + - wait for process G to start + - check for no project A processes?? But project A doesn't filter out by sender, and the sender used in the second event would be allowed through them anyways + - this test obviously runs on other branches, what am I missing here + + */ + deleteOrg(orgXName); } diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/ProcessCountIT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/ProcessCountIT.java index c73d4a5029..f6c415f157 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/ProcessCountIT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/ProcessCountIT.java @@ -51,7 +51,7 @@ public void test() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/ProjectIT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/ProjectIT.java index 8e43b2c132..1079c62b28 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/ProjectIT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/ProjectIT.java @@ -50,7 +50,7 @@ public void test() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- @@ -88,7 +88,7 @@ public void testEntryPointFromYml() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- @@ -137,7 +137,7 @@ public void testWithCommitId() throws Exception { repo.add().addFilepattern(".").call(); repo.commit().setMessage("commit-2").call(); - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- @@ -189,7 +189,7 @@ public void testWithTag() throws Exception { repo.commit().setMessage("commit-2").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- String projectName = "myProject_" + randomString(); @@ -221,7 +221,7 @@ public void testInitImport() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- @@ -258,7 +258,7 @@ public void testRepositoryValidation() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- @@ -292,7 +292,7 @@ public void testRepositoryValidationForEmptyFlow() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- @@ -327,7 +327,7 @@ public void testRepositoryValidationForEmptyForm() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- @@ -362,7 +362,7 @@ public void testDisabledRepository() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- String orgName = "Default"; diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/SystemResourceIT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/SystemResourceIT.java new file mode 100644 index 0000000000..f8585de759 --- /dev/null +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/SystemResourceIT.java @@ -0,0 +1,82 @@ +package com.walmartlabs.concord.it.server; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2018 Walmart 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. + * ===== + */ + +import com.walmartlabs.concord.client2.ApiException; +import com.walmartlabs.concord.client2.RoleEntry; +import com.walmartlabs.concord.client2.RolesApi; +import com.walmartlabs.concord.client2.SystemApi; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SystemResourceIT extends AbstractServerIT { + + private static final URI URI001 = URI.create("https://github001.local/owner/repo.git"); + private static final URI URI002 = URI.create("https://github002.local/owner/repo.git"); + + @Test + void testGetExternalToken() throws Exception { + // create role with externalTokenLookup permission + var roleApi = new RolesApi(getApiClient()); + var roleName = "token_lookup_role_" + randomString(); + + roleApi.createOrUpdateRole(new RoleEntry() + .name(roleName) + .permissions(Set.of("externalTokenLookup"))); + + // user with externalTokenLookup role + var userBName = "user_external_token_lookup_" + randomString(); + var externalTokenLookupUser = addUser(userBName, Set.of(roleName)); + + // get system-provided token with externalTokenLookup role + var systemApi = new SystemApi(getApiClientForKey(externalTokenLookupUser.apiKey())); + var token = assertDoesNotThrow(() -> systemApi.getExternalToken(URI001)); + assertEquals("mock-token", token.getToken()); + assertNull(token.getUsername()); + + // again, but from config that provides username + token = assertDoesNotThrow(() -> systemApi.getExternalToken(URI002)); + assertEquals("mock-token", token.getToken()); + assertNotNull(token.getUsername()); + assertEquals("customUser", token.getUsername()); + } + + @Test + void testGetExternalTokenNoPermission() throws Exception { + // user with no roles + var userAName = "user_basic_" + randomString(); + var noRolesUser = addUser(userAName, Set.of()); + + // attempt to get system-provided token with insufficient privileges + var systemApiNoPerm = new SystemApi(getApiClientForKey(noRolesUser.apiKey())); + var ex1 = assertThrows(ApiException.class, () -> systemApiNoPerm.getExternalToken(URI001)); + assertEquals(403, ex1.getCode()); + } + +} diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/TemplateIT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/TemplateIT.java index bd193dd1ec..62df7bf783 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/TemplateIT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/TemplateIT.java @@ -161,7 +161,7 @@ public void testEntryPointReference() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/TriggerIT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/TriggerIT.java index 425714a616..c8a38e853c 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/TriggerIT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/TriggerIT.java @@ -132,7 +132,7 @@ private ProjectOperationResponse createProject(String orgName, String projectNam repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/TriggersRefreshIT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/TriggersRefreshIT.java index fbf4fb7482..f6687e97f1 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/TriggersRefreshIT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/TriggersRefreshIT.java @@ -51,7 +51,7 @@ public void testTriggerRepoRefresh() throws Exception { repo.commit().setMessage("import").call(); } - String gitUrl = tmpDir.toAbsolutePath().toString(); + String gitUrl = "file://" + tmpDir.toAbsolutePath().toString(); // --- diff --git a/it/server/src/test/java/com/walmartlabs/concord/it/server/UserResourceV2IT.java b/it/server/src/test/java/com/walmartlabs/concord/it/server/UserResourceV2IT.java index a5608cbe8f..c183de1aa3 100644 --- a/it/server/src/test/java/com/walmartlabs/concord/it/server/UserResourceV2IT.java +++ b/it/server/src/test/java/com/walmartlabs/concord/it/server/UserResourceV2IT.java @@ -21,13 +21,8 @@ */ import com.walmartlabs.concord.client2.ApiException; -import com.walmartlabs.concord.client2.ApiKeysApi; -import com.walmartlabs.concord.client2.CreateApiKeyRequest; -import com.walmartlabs.concord.client2.CreateUserRequest; -import com.walmartlabs.concord.client2.UpdateUserRolesRequest; import com.walmartlabs.concord.client2.UserEntry; import com.walmartlabs.concord.client2.UserV2Api; -import com.walmartlabs.concord.client2.UsersApi; import org.junit.jupiter.api.Test; import java.util.Set; @@ -61,28 +56,10 @@ void testGetUser() throws Exception { assertEquals(user.getId(), noRolesUser.userId()); } - private UserEntry getUser(UserInfo userInfo, UUID userToGet) throws ApiException { + protected UserEntry getUser(UserInfo userInfo, UUID userToGet) throws ApiException { var apiClient = new UserV2Api(getApiClientForKey(userInfo.apiKey())); return apiClient.getUser(userToGet); } - private UserInfo addUser(String username, Set roles) throws ApiException { - var usersApi = new UsersApi(getApiClient()); - var user = usersApi.createOrUpdateUser(new CreateUserRequest().username(username) - .type(CreateUserRequest.TypeEnum.LOCAL)); - - if (!roles.isEmpty()) { - usersApi.updateUserRoles(username, new UpdateUserRolesRequest() - .roles(roles)); - } - - var apiKeysApi = new ApiKeysApi(getApiClient()); - var apiKeyResp = apiKeysApi.createUserApiKey(new CreateApiKeyRequest() - .userId(user.getId())); - - return new UserInfo(username, user.getId(), apiKeyResp.getKey()); - } - - private record UserInfo(String username, UUID userId, String apiKey) { } } diff --git a/it/server/src/test/resources/agent.conf b/it/server/src/test/resources/agent.conf index 5f0627cef3..016572fe3c 100644 --- a/it/server/src/test/resources/agent.conf +++ b/it/server/src/test/resources/agent.conf @@ -8,6 +8,10 @@ concord-agent { enabled = true } + git { + allowedSchemes = [ "file", "https", "ssh", "classpath" ] + } + capabilities = { type = "test" } @@ -16,4 +20,14 @@ concord-agent { apiKey = "cTJxMnEycTI=" processRequestDelay = "250 milliseconds" } + + externalTokenProvider { + enabled = true + # Regex matching URI host + port + path for providing lookup for + # external auth tokens. URI scheme is ignored. Requires externalTokenLookup + # permission for the client user. + # e.g. "github.com/my-org/" or "github.com/(orgA|orgB))/" + urlPattern = "github(\\d+).local/.*" + } + } diff --git a/it/server/src/test/resources/server.conf b/it/server/src/test/resources/server.conf index 273a4d241a..7fd3a63b3a 100644 --- a/it/server/src/test/resources/server.conf +++ b/it/server/src/test/resources/server.conf @@ -23,6 +23,17 @@ concord-server { secret = "12345" useSenderLdapDn = true disableReposOnDeletedRef = true + + appInstallation { + auth = [ + { type = "OAUTH_TOKEN", urlPattern = "(?github001.local)/", token = "mock-token" }, + { type = "OAUTH_TOKEN", username = "customUser", urlPattern = "(?github002.local)/", token = "mock-token" }, + ] + } + } + + git { + allowedSchemes = [ "file", "https", "ssh", "classpath" ] } ldap { diff --git a/pom.xml b/pom.xml index 76dcff2c65..8fdf233c06 100644 --- a/pom.xml +++ b/pom.xml @@ -37,6 +37,7 @@ common policy-engine dependency-manager + github-app-installation repository imports forms diff --git a/repository/pom.xml b/repository/pom.xml index 7d25a99879..18c541847f 100644 --- a/repository/pom.xml +++ b/repository/pom.xml @@ -85,5 +85,15 @@ org.eclipse.jgit test + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + diff --git a/repository/src/main/java/com/walmartlabs/concord/repository/FetchRequest.java b/repository/src/main/java/com/walmartlabs/concord/repository/FetchRequest.java index 7ab3812867..9e2534a4ea 100644 --- a/repository/src/main/java/com/walmartlabs/concord/repository/FetchRequest.java +++ b/repository/src/main/java/com/walmartlabs/concord/repository/FetchRequest.java @@ -41,8 +41,10 @@ public interface FetchRequest { Path destination(); /** - * Concord secret for authentication. - * If not provided {@link GitClientConfiguration#oauthToken()} is used for {@code https://} URLs. + * Concord secret for authentication. If not provided, an available + * {@link com.walmartlabs.concord.common.AuthTokenProvider} matching + * {@link #url()} will be used for {@code https://} URLs. Otherwise, + * anonymous auth is attempted. */ @Nullable Secret secret(); diff --git a/repository/src/main/java/com/walmartlabs/concord/repository/GitCliRepositoryProvider.java b/repository/src/main/java/com/walmartlabs/concord/repository/GitCliRepositoryProvider.java index 2ab61cd447..99b9de7947 100644 --- a/repository/src/main/java/com/walmartlabs/concord/repository/GitCliRepositoryProvider.java +++ b/repository/src/main/java/com/walmartlabs/concord/repository/GitCliRepositoryProvider.java @@ -20,6 +20,7 @@ * ===== */ +import com.walmartlabs.concord.common.AuthTokenProvider; import com.walmartlabs.concord.common.PathUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,8 +38,8 @@ public class GitCliRepositoryProvider implements RepositoryProvider { private final GitClient client; - public GitCliRepositoryProvider(GitClientConfiguration cfg) { - this.client = new GitClient(cfg); + public GitCliRepositoryProvider(GitClientConfiguration cfg, AuthTokenProvider authProvider) { + this.client = new GitClient(cfg, authProvider); } @Override diff --git a/repository/src/main/java/com/walmartlabs/concord/repository/GitClient.java b/repository/src/main/java/com/walmartlabs/concord/repository/GitClient.java index f9642b9c3d..0ff7c67a45 100644 --- a/repository/src/main/java/com/walmartlabs/concord/repository/GitClient.java +++ b/repository/src/main/java/com/walmartlabs/concord/repository/GitClient.java @@ -20,7 +20,7 @@ * ===== */ -import com.google.common.collect.ImmutableSet; +import com.walmartlabs.concord.common.AuthTokenProvider; import com.walmartlabs.concord.common.PathUtils; import com.walmartlabs.concord.common.secret.BinaryDataSecret; import com.walmartlabs.concord.common.secret.KeyPair; @@ -54,14 +54,23 @@ public class GitClient { private static final int SUCCESS_EXIT_CODE = 0; private final GitClientConfiguration cfg; + private final AuthTokenProvider authProvider; - private final List sensitiveData; + private final Set sensitiveData; private final ExecutorService executor; - public GitClient(GitClientConfiguration cfg) { + public GitClient(GitClientConfiguration cfg, AuthTokenProvider authProvider) { this.cfg = cfg; - this.sensitiveData = cfg.oauthToken() != null ? Collections.singletonList(cfg.oauthToken()) : Collections.emptyList(); + this.authProvider = authProvider; this.executor = Executors.newCachedThreadPool(); + this.sensitiveData = new LinkedHashSet<>(); + + // urls with user info. + sensitiveData.add(new Obfuscation("https://([^@]*)@", "https://***@")); + + cfg.oauthToken().ifPresent(oauth -> + // definitely don't print a hard-code oauth token + sensitiveData.add(new Obfuscation(oauth, "***"))); } public FetchResult fetch(FetchRequest req) { @@ -121,6 +130,8 @@ public FetchResult fetch(FetchRequest req) { } } + record Obfuscation(String pattern, String replacement) {} + private boolean alreadyFetched(Path workDir, Ref ref, NormalizedVersion version) { String head = revParse(workDir, "HEAD"); @@ -310,23 +321,22 @@ String updateUrl(String url, Secret secret) { URI uri = assertUriAllowed(url); - List allowedDefaultHosts = cfg.authorizedGitHosts(); - - if(secret == null && allowedDefaultHosts!= null && !allowedDefaultHosts.contains(uri.getHost())) { - // in this case the user has not provided authentication AND the host is not in the whitelist of hosts - // which may use the default git credentials. return the url un-modified to attempt anonymous auth; - // if it fails + if (url.contains("@") || !url.startsWith("https://")) { + // provided url already has credentials OR it's a non-https url return url; } - if (secret != null || cfg.oauthToken() == null || url.contains("@") || !url.startsWith("https://")) { - // provided url already has credentials OR there are no default credentials to use. - // anonymous auth is the only viable option. - return url; + if (secret instanceof UsernamePassword up) { + // Secret contains static auth (token or username). No lookup needed. + return "https://" + + (up.getUsername() == null ? "" : up.getUsername() + ":") + + String.valueOf(up.getPassword()) + + "@" + + url.substring("https://".length()); } - // using default credentials - return "https://" + cfg.oauthToken() + "@" + url.substring("https://".length()); + // This will either add auth from a matching provider, or none for anonymous access + return authProvider.addUserInfoToUri(uri, secret).toString(); } private URI assertUriAllowed(String rawUri) { @@ -554,9 +564,7 @@ private String execWithCredentials(Command cmd, Secret secret) { env.put("GIT_TERMINAL_PROMPT", "0"); try { - if (secret instanceof KeyPair) { - KeyPair keyPair = (KeyPair) secret; - + if (secret instanceof KeyPair keyPair) { key = createSshKeyFile(keyPair); ssh = createUnixGitSSH(key); @@ -569,18 +577,14 @@ private String execWithCredentials(Command cmd, Secret secret) { } log.info("using GIT_SSH to set credentials"); - } else if (secret instanceof UsernamePassword) { - UsernamePassword userPass = (UsernamePassword) secret; - + } else if (secret instanceof UsernamePassword userPass) { askpass = createUnixStandardAskpass(userPass); env.put("GIT_ASKPASS", askpass.toAbsolutePath().toString()); env.put("SSH_ASKPASS", askpass.toAbsolutePath().toString()); log.info("using GIT_ASKPASS to set credentials "); - } else if (secret instanceof BinaryDataSecret) { - BinaryDataSecret token = (BinaryDataSecret) secret; - + } else if (secret instanceof BinaryDataSecret token) { askpass = createUnixStandardAskpass(new UsernamePassword(new String(token.getData()), "".toCharArray())); env.put("GIT_ASKPASS", askpass.toAbsolutePath().toString()); @@ -608,8 +612,8 @@ private String hideSensitiveData(String s) { return null; } - for (String p : sensitiveData) { - s = s.replaceAll(p, "***"); + for (Obfuscation o : sensitiveData) { + s = s.replaceAll(o.pattern(), o.replacement()); } return s; } @@ -628,7 +632,7 @@ private Path createUnixGitSSH(Path key) throws IOException { " -o ServerAliveInterval=" + cfg.sshTimeout().getSeconds() + " -o StrictHostKeyChecking=no \"$@\""); } - Files.setPosixFilePermissions(ssh, ImmutableSet.of(OWNER_READ, OWNER_EXECUTE)); + Files.setPosixFilePermissions(ssh, Set.of(OWNER_READ, OWNER_EXECUTE)); return ssh; } @@ -647,7 +651,7 @@ private static Path createUnixStandardAskpass(UsernamePassword creds) throws IOE w.println("Password*) echo '" + quoteUnixCredentials(new String(creds.getPassword())) + "' ;;"); w.println("esac"); } - Files.setPosixFilePermissions(askpass, ImmutableSet.of(OWNER_READ, OWNER_EXECUTE)); + Files.setPosixFilePermissions(askpass, Set.of(OWNER_READ, OWNER_EXECUTE)); return askpass; } diff --git a/repository/src/main/java/com/walmartlabs/concord/repository/GitClientConfiguration.java b/repository/src/main/java/com/walmartlabs/concord/repository/GitClientConfiguration.java index 122dd3467e..524b61b45f 100644 --- a/repository/src/main/java/com/walmartlabs/concord/repository/GitClientConfiguration.java +++ b/repository/src/main/java/com/walmartlabs/concord/repository/GitClientConfiguration.java @@ -22,20 +22,19 @@ import org.immutables.value.Value; -import javax.annotation.Nullable; import java.time.Duration; -import java.util.List; import java.util.Set; +import java.util.Optional; @Value.Immutable @Value.Style(jdkOnly = true) public interface GitClientConfiguration { - @Nullable - String oauthToken(); - - @Nullable - List authorizedGitHosts(); + Optional oauthToken(); + + Optional oauthUsername(); + + Optional oauthUrlPattern(); @Value.Default default Set allowedSchemes() { diff --git a/repository/src/main/java/com/walmartlabs/concord/repository/RepositoryException.java b/repository/src/main/java/com/walmartlabs/concord/repository/RepositoryException.java index fecc45e3e0..d1241153b1 100644 --- a/repository/src/main/java/com/walmartlabs/concord/repository/RepositoryException.java +++ b/repository/src/main/java/com/walmartlabs/concord/repository/RepositoryException.java @@ -21,8 +21,11 @@ */ +import java.io.Serial; + public class RepositoryException extends RuntimeException { + @Serial private static final long serialVersionUID = 1L; public RepositoryException(String message) { @@ -32,4 +35,14 @@ public RepositoryException(String message) { public RepositoryException(String message, Throwable cause) { super(message, cause); } + + public static class NotFoundException extends RepositoryException { + public NotFoundException(String message) { + super(message); + } + + public NotFoundException(String message, Throwable cause) { + super(message, cause); + } + } } diff --git a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetch2Test.java b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetch2Test.java index fbd22cd0f2..5cb9a3274b 100644 --- a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetch2Test.java +++ b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetch2Test.java @@ -20,17 +20,22 @@ * ===== */ +import com.walmartlabs.concord.common.AuthTokenProvider; import com.walmartlabs.concord.common.PathUtils; import com.walmartlabs.concord.common.TemporaryPath; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; +import java.util.Arrays; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -38,18 +43,23 @@ /** * test for checkout prev commitId on master branch + checkAlreadyFetched=true */ +@ExtendWith(MockitoExtension.class) public class GitClientFetch2Test { private GitClient client; + @Mock + AuthTokenProvider authProvider; + @BeforeEach public void init() { client = new GitClient(GitClientConfiguration.builder() + .addAllAllowedSchemes(Arrays.asList("http", "https", "file")) .sshTimeout(Duration.ofMinutes(10)) .sshTimeoutRetryCount(1) .httpLowSpeedLimit(1) .httpLowSpeedTime(Duration.ofMinutes(10)) - .build()); + .build(), authProvider); } @Test @@ -60,13 +70,13 @@ public void testFetchByBranchAndPrevCommit() throws Exception { try (TemporaryPath repoPath = PathUtils.tempDir("git-client-test")) { // fetch master - fetch(repo.toString(), "master", null, repoPath.path()); + fetch(repo, "master", null, repoPath.path()); assertContent(repoPath, "master.txt", "master"); assertContent(repoPath, "0_concord.yml", "0-concord-content"); assertContent(repoPath, "1_concord.yml", "1-concord-content"); // fetch master+prev commitId - fetch(repo.toString(), "master", commit0.name(), repoPath.path()); + fetch(repo, "master", commit0.name(), repoPath.path()); assertNoContent(repoPath, "1_concord.yml"); } } @@ -79,7 +89,7 @@ public void testFetchByPrevCommit() throws Exception { try (TemporaryPath repoPath = PathUtils.tempDir("git-client-test")) { // fetch master - fetch(repo.toString(), "master", null, repoPath.path()); + fetch(repo, "master", null, repoPath.path()); assertContent(repoPath, "master.txt", "master"); assertContent(repoPath, "0_concord.yml", "0-concord-content"); assertContent(repoPath, "1_concord.yml", "1-concord-content"); @@ -87,7 +97,7 @@ public void testFetchByPrevCommit() throws Exception { System.out.println("fetching prev commit"); // fetch prev commitId - fetch(repo.toString(), null, commit0.name(), repoPath.path()); + fetch(repo, null, commit0.name(), repoPath.path()); assertNoContent(repoPath, "1_concord.yml"); } } @@ -100,7 +110,7 @@ public void testReFetch() throws Exception { try (TemporaryPath repoPath = PathUtils.tempDir("git-client-test")) { // fetch master - fetch(repo.toString(), "master", null, repoPath.path()); + fetch(repo, "master", null, repoPath.path()); assertContent(repoPath, "master.txt", "master"); assertContent(repoPath, "0_concord.yml", "0-concord-content"); assertContent(repoPath, "1_concord.yml", "1-concord-content"); @@ -108,14 +118,14 @@ public void testReFetch() throws Exception { System.out.println("refetching"); // refetch master - fetch(repo.toString(), "master", null, repoPath.path()); + fetch(repo, "master", null, repoPath.path()); assertContent(repoPath, "1_concord.yml", "1-concord-content"); } } - private String fetch(String repoUri, String branch, String commitId, Path dest) { + private String fetch(Path repo, String branch, String commitId, Path dest) { return client.fetch(FetchRequest.builder() - .url(repoUri) + .url("file://" + repo.toString()) .version(FetchRequest.Version.commitWithBranch(commitId, branch)) .destination(dest) .shallow(true) diff --git a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetchTest.java b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetchTest.java index 7c608fed05..84d0ff9e71 100644 --- a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetchTest.java +++ b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetchTest.java @@ -20,6 +20,7 @@ * ===== */ +import com.walmartlabs.concord.common.AuthTokenProvider; import com.walmartlabs.concord.common.PathUtils; import com.walmartlabs.concord.common.TemporaryPath; import com.walmartlabs.concord.sdk.Secret; @@ -28,8 +29,12 @@ import org.eclipse.jgit.revwalk.RevCommit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -40,10 +45,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +@ExtendWith(MockitoExtension.class) public class GitClientFetchTest { private GitClient client; + @Mock + AuthTokenProvider authProvider; + @BeforeEach public void init() { client = new GitClient(GitClientConfiguration.builder() @@ -52,7 +61,7 @@ public void init() { .sshTimeoutRetryCount(1) .httpLowSpeedLimit(1) .httpLowSpeedTime(Duration.ofMinutes(10)) - .build()); + .build(), authProvider); } @Test @@ -67,8 +76,9 @@ public void testFetch1() throws Exception { RevCommit initialCommit = commit(repo, "import"); try (TemporaryPath repoPath = PathUtils.tempDir("git-client-test")) { + URI tmpDirURI = URI.create(repoPath.toString()); // --- fetch master - String actualCommitId = fetch(tmpDir.toUri().toString(), "master", null, null, repoPath.path()); + String actualCommitId = fetch(tmpDir, "master", null, null, repoPath.path()); assertContent(repoPath, "concord.yml", "concord-init"); assertEquals(initialCommit.name(), actualCommitId); @@ -78,12 +88,12 @@ public void testFetch1() throws Exception { RevCommit commitAfterUpdate = commit(repo, "update"); // --- fetch prev commit - String prevCommit = fetch(tmpDir.toUri().toString(), "master", initialCommit.name(), null, repoPath.path()); + String prevCommit = fetch(tmpDir, "master", initialCommit.name(), null, repoPath.path()); assertContent(repoPath, "concord.yml", "concord-init"); assertEquals(initialCommit.name(), prevCommit); // --- fetch master again - actualCommitId = fetch(tmpDir.toUri().toString(), "master", null, null, repoPath.path()); + actualCommitId = fetch(tmpDir, "master", null, null, repoPath.path()); assertContent(repoPath, "concord.yml", "new-concord-content"); assertEquals(commitAfterUpdate.name(), actualCommitId); } @@ -101,7 +111,7 @@ public void testFetch2() throws Exception { for (int i = 0; i < 3; i++) { String commitId = commits.get(i).name(); try (TemporaryPath repoPath = PathUtils.tempDir("git-client-test")) { - String result = fetch(repo.toString(), "master", commitId, null, repoPath.path()); + String result = fetch(repo, "master", commitId, null, repoPath.path()); assertContent(repoPath, i + "_concord.yml", i + "-concord-content"); assertEquals(commitId, result); } @@ -111,7 +121,7 @@ public void testFetch2() throws Exception { for (int i = 0; i < 3; i++) { String commitId = commits.get(i).name(); try (TemporaryPath repoPath = PathUtils.tempDir("git-client-test")) { - String result = fetch(repo.toString(), null, commitId, null, repoPath.path()); + String result = fetch(repo, null, commitId, null, repoPath.path()); assertContent(repoPath, i + "_concord.yml", i + "-concord-content"); assertEquals(commitId, result); } @@ -129,42 +139,42 @@ public void testFetch3() throws Exception { try (TemporaryPath repoPath = PathUtils.tempDir("git-client-test")) { // fetch master - fetch(repo.toString(), "master", null, null, repoPath.path()); + fetch(repo, "master", null, null, repoPath.path()); assertContent(repoPath, "master.txt", "master"); // fetch branch - commitId = fetch(repo.toString(), "branch-1", null, null, repoPath.path()); + commitId = fetch(repo, "branch-1", null, null, repoPath.path()); assertContent(repoPath, "branch-1.txt", "branch-1"); // fetch tag - tagCommitId = fetch(repo.toString(), "tag-1", null, null, repoPath.path()); + tagCommitId = fetch(repo, "tag-1", null, null, repoPath.path()); assertContent(repoPath, "tag-1.txt", "tag-1"); // fetch by commit - fetch(repo.toString(), null, commitId, null, repoPath.path()); + fetch(repo, null, commitId, null, repoPath.path()); assertContent(repoPath, "branch-1.txt", "branch-1"); - fetch(repo.toString(), null, tagCommitId, null, repoPath.path()); + fetch(repo, null, tagCommitId, null, repoPath.path()); assertContent(repoPath, "tag-1.txt", "tag-1"); } // fetch by commit with clean repo try (TemporaryPath repoPath = PathUtils.tempDir("git-client-test")) { - String result = fetch(repo.toString(), "branch-1", commitId, null, repoPath.path()); + String result = fetch(repo, "branch-1", commitId, null, repoPath.path()); assertContent(repoPath, "branch-1.txt", "branch-1"); assertEquals(result, commitId); } // fetch by commit with clean repo try (TemporaryPath repoPath = PathUtils.tempDir("git-client-test")) { - String result = fetch(repo.toString(), "tag-1", tagCommitId, null, repoPath.path()); + String result = fetch(repo, "tag-1", tagCommitId, null, repoPath.path()); assertContent(repoPath, "tag-1.txt", "tag-1"); assertEquals(result, tagCommitId); } // fetch by commit with clean repo and without branch -> should fetch all repo and checkout commit-id try (TemporaryPath repoPath = PathUtils.tempDir("git-client-test")) { - String result = fetch(repo.toString(), null, commitId, null, repoPath.path()); + String result = fetch(repo, null, commitId, null, repoPath.path()); assertContent(repoPath, "branch-1.txt", "branch-1"); assertEquals(result, commitId); } @@ -172,18 +182,18 @@ public void testFetch3() throws Exception { // fetch same branch two times try (TemporaryPath repoPath = PathUtils.tempDir("git-client-test")) { // fetch branch - fetch(repo.toString(), "branch-1", null, null, repoPath.path()); + fetch(repo, "branch-1", null, null, repoPath.path()); assertContent(repoPath, "branch-1.txt", "branch-1"); // fetch branch - fetch(repo.toString(), "branch-1", null, null, repoPath.path()); + fetch(repo, "branch-1", null, null, repoPath.path()); assertContent(repoPath, "branch-1.txt", "branch-1"); } } - private String fetch(String repoUri, String branch, String commitId, Secret secret, Path dest) { + private String fetch(Path path, String branch, String commitId, Secret secret, Path dest) { return client.fetch(FetchRequest.builder() - .url(repoUri) + .url("file://" + path) .version(FetchRequest.Version.commitWithBranch(commitId, branch)) .secret(secret) .destination(dest) diff --git a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientRealTest.java b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientRealTest.java index 5f717fc010..6950f5aa83 100644 --- a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientRealTest.java +++ b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientRealTest.java @@ -20,6 +20,7 @@ * ===== */ +import com.walmartlabs.concord.common.AuthTokenProvider; import com.walmartlabs.concord.common.PathUtils; import com.walmartlabs.concord.common.TemporaryPath; import com.walmartlabs.concord.common.secret.KeyPair; @@ -28,6 +29,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; import java.nio.file.Files; @@ -38,6 +42,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; @Disabled +@ExtendWith(MockitoExtension.class) public class GitClientRealTest { private static final String HTTPS_REPO_URL = System.getenv("HTTPS_REPO_URL"); @@ -48,6 +53,9 @@ public class GitClientRealTest { private static final Secret USERNAME_PASSWORD = new UsernamePassword(System.getenv("GIT_TEST_USER"), System.getenv("GIT_TEST_USER_PASSWD").toCharArray()); private static final Secret KEYPAIR = createKeypair(); + @Mock + AuthTokenProvider authProvider; + private static Secret createKeypair() { try { return new KeyPair( @@ -69,7 +77,7 @@ public void init() { .sshTimeoutRetryCount(1) .httpLowSpeedLimit(1) .httpLowSpeedTime(Duration.ofMinutes(10)) - .build()); + .build(), authProvider); } @Test diff --git a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientSpeedTest.java b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientSpeedTest.java index dc906b98ec..7b100bd575 100644 --- a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientSpeedTest.java +++ b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientSpeedTest.java @@ -20,6 +20,7 @@ * ===== */ +import com.walmartlabs.concord.common.AuthTokenProvider; import com.walmartlabs.concord.common.PathUtils; import com.walmartlabs.concord.common.TemporaryPath; import com.walmartlabs.concord.sdk.Secret; @@ -27,6 +28,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; import java.nio.file.Files; @@ -54,10 +58,14 @@ * Require internet connection */ @Disabled +@ExtendWith(MockitoExtension.class) public class GitClientSpeedTest { private GitClient client; + @Mock + AuthTokenProvider authProvider; + @BeforeEach public void init() { client = new GitClient(GitClientConfiguration.builder() @@ -65,7 +73,7 @@ public void init() { .sshTimeoutRetryCount(1) .httpLowSpeedLimit(1) .httpLowSpeedTime(Duration.ofMinutes(10)) - .build()); + .build(), authProvider); } @Test diff --git a/repository/src/test/java/com/walmartlabs/concord/repository/GitUriTest.java b/repository/src/test/java/com/walmartlabs/concord/repository/GitUriTest.java index 933560689a..5462c7e98b 100644 --- a/repository/src/test/java/com/walmartlabs/concord/repository/GitUriTest.java +++ b/repository/src/test/java/com/walmartlabs/concord/repository/GitUriTest.java @@ -20,19 +20,34 @@ * ===== */ +import com.walmartlabs.concord.common.AuthTokenProvider; import com.walmartlabs.concord.common.secret.BinaryDataSecret; import com.walmartlabs.concord.sdk.Secret; +import org.immutables.value.Value; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class GitUriTest { + private static final AuthTokenProvider AUTH_PROVIDER = authProvider(null); + private static final AuthTokenProvider RESTRICTED_AUTH_PROVIDER = authProvider("gitserver.local"); private static final GitClientConfiguration cfg = GitClientConfiguration.builder() .oauthToken("mock-token") .build(); - private static final GitClient client = new GitClient(cfg); - private static Secret secret = new BinaryDataSecret(new byte[0]); + private static final GitClient client = new GitClient(cfg, AUTH_PROVIDER); + private static Secret secret = new BinaryDataSecret("secret-mock-token".getBytes()); + + private static AuthTokenProvider authProvider(String urlPattern) { + var builder = TestOauthTokenConfig.builder() + .oauthToken("mock-token"); + + if (urlPattern != null) { + builder.oauthUrlPattern(urlPattern); + } + + return new AuthTokenProvider.OauthTokenProvider(builder.build()); + } @Test void testSsh() { @@ -53,7 +68,7 @@ void testHttps() { @Test void testHttpWithSecret() { var httpsSecret = client.updateUrl("https://gitserver.local/my-org/my-repo.git", secret); - assertEquals("https://gitserver.local/my-org/my-repo.git", httpsSecret); + assertEquals("https://secret-mock-token@gitserver.local/my-org/my-repo.git", httpsSecret); } @Test @@ -72,8 +87,7 @@ void testUnrestrictedHost() { void testGitHostRestriction() { var restrictedClient = new GitClient(GitClientConfiguration.builder() .from(cfg) - .addAuthorizedGitHosts("gitserver.local") - .build()); + .build(), RESTRICTED_AUTH_PROVIDER); var anonAuth = restrictedClient.updateUrl("https://elsewhere.local/my-org/my-repo.git", null); // unchanged @@ -84,4 +98,12 @@ void testGitHostRestriction() { assertEquals("https://mock-token@gitserver.local/my-org/my-repo.git", url2); } + @Value.Immutable + @Value.Style(jdkOnly = true) + interface TestOauthTokenConfig extends com.walmartlabs.concord.common.cfg.OauthTokenConfig { + static ImmutableTestOauthTokenConfig.Builder builder() { + return ImmutableTestOauthTokenConfig.builder(); + } + } + } diff --git a/server/db/src/main/resources/com/walmartlabs/concord/server/db/liquibase.xml b/server/db/src/main/resources/com/walmartlabs/concord/server/db/liquibase.xml index b46856f864..47347b62e6 100644 --- a/server/db/src/main/resources/com/walmartlabs/concord/server/db/liquibase.xml +++ b/server/db/src/main/resources/com/walmartlabs/concord/server/db/liquibase.xml @@ -119,4 +119,5 @@ + diff --git a/server/db/src/main/resources/com/walmartlabs/concord/server/db/v2.32.0.xml b/server/db/src/main/resources/com/walmartlabs/concord/server/db/v2.32.0.xml new file mode 100644 index 0000000000..4ebb4da5bc --- /dev/null +++ b/server/db/src/main/resources/com/walmartlabs/concord/server/db/v2.32.0.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + insert into ROLE_PERMISSIONS values ( + (select ROLE_ID from ROLES where ROLE_NAME = 'concordAdmin'), + '${externalTokenLookupPermissionId}' + ) + + + diff --git a/server/dist/src/main/resources/concord-server.conf b/server/dist/src/main/resources/concord-server.conf index f4fba570db..aa8c1194e5 100644 --- a/server/dist/src/main/resources/concord-server.conf +++ b/server/dist/src/main/resources/concord-server.conf @@ -44,6 +44,17 @@ concord-server { } } + git { + # system-provided authentication configs. May include static access tokens + # or GitHub app installation details (client id, path to private key) + systemAuth = [] + # max duration to cache tokens after create/load. + # GitHub installation tokens typically expire after 1 hour. + systemAuthCacheDuration = "50 minutes" + # max size of token cache + systemAuthCacheMaxSize = 1000 + } + # main database connection db { # (optional) JDBC URL of the database @@ -463,9 +474,29 @@ concord-server { # git clone config git { - # GitHub auth token to use when cloning repositories without explicitly configured authentication + allowedSchemes = [ "https", "ssh", "classpath" ] + + # GitHub auth token to use when cloning repositories without explicitly + # configured authentication. Deprecated in favor of systemAuth list of + # tokens or service-specific app config (e.g. github) # oauth = "..." + # specific username to use for auth + # oauthUsername = "" + + # regex to match against git server's hostname + port + path so oauth + # token isn't used for and unexpected host + # oauthUrlPattern = "" + + # List of system-provided auth token configs + # { + # type = "OAUTH_TOKEN", + # token = "...", + # username = "...", # optional, username to send with auth token + # urlPattern = "..." # required, regex to match against target git host + port + path + # } + systemAuth = [] + # use GIT's shallow clone shallowClone = true @@ -487,7 +518,7 @@ concord-server { sshTimeout = "10 minutes" } - # GitHub webhook integration + # GitHub app and webhook integration github { # default value, for testing only secret = "12345" @@ -500,6 +531,42 @@ concord-server { # disable concord repos on push event with deleted ref (branch, tag) disableReposOnDeletedRef = false + + # App installation settings. Multiple auth (private key) definitions are supported, + # as each is matched to a particular url pattern. + appInstallation { + # Importantly, urlPattern must include a regex capture group named 'baseUrl'. + # This is necessary to detect where the owner/repo values begin in the path. + # { + # type = "GITHUB_APP_INSTALLATION", + # urlPattern = "(?github.com)", # regex + # username = "...", # optional, defaults to "x-access-token" + # apiUrl = "https://api.github.com", # github API url, usually *not* the same as the repo url host/path + # clientId = "...", + # privateKey = "/path/to/pk.pem" + # } + # or static oauth config. Not exactly a "GitHub App", but can do some + # API interactions and cloning. Actual app installation is preferred. + # { + # type = "OAUTH_TOKEN", + # token = "...", + # username = "...", # optional, usually not necessary + # urlPattern = "..." # regex to match against git server's hostname + port + path + # } + auth = [] + + # Timeout duration for http calls from the app + httpClientTimeout = "30 seconds" + + # Duration of time to keep tokens cached after creation. May be purged + # earlier depending on overall cache weight and usage. GitHub installation + # tokens expire after 1 hour + systemAuthCacheDuration = "50 minutes" + + # Max "weight" of the token cache. Defaults to ~10MB which should hold + # between 3,500 and 10,000 tokens depending on associated secret size. + systemAuthCacheMaxWeight = 10240 + } } # Ansible event processor configuration diff --git a/server/impl/pom.xml b/server/impl/pom.xml index 2730bc0bfd..61b1d42a83 100644 --- a/server/impl/pom.xml +++ b/server/impl/pom.xml @@ -102,6 +102,10 @@ com.walmartlabs.concord concord-common + + com.walmartlabs.concord + concord-github-app-installation + io.takari.bpm bpm-engine-api diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/ConcordServerModule.java b/server/impl/src/main/java/com/walmartlabs/concord/server/ConcordServerModule.java index b542e17542..921d50a672 100644 --- a/server/impl/src/main/java/com/walmartlabs/concord/server/ConcordServerModule.java +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/ConcordServerModule.java @@ -24,6 +24,7 @@ import com.google.inject.Binder; import com.google.inject.Module; import com.typesafe.config.Config; +import com.walmartlabs.concord.common.AuthTokenProvider; import com.walmartlabs.concord.common.ObjectMapperProvider; import com.walmartlabs.concord.config.ConfigModule; import com.walmartlabs.concord.db.DatabaseModule; @@ -39,8 +40,10 @@ import com.walmartlabs.concord.server.message.MessageChannelManager; import com.walmartlabs.concord.server.metrics.MetricModule; import com.walmartlabs.concord.server.org.OrganizationModule; +import com.walmartlabs.concord.server.org.secret.SystemResource; import com.walmartlabs.concord.server.policy.PolicyModule; import com.walmartlabs.concord.server.process.ProcessModule; +import com.walmartlabs.concord.server.repository.ServerAuthTokenProvider; import com.walmartlabs.concord.server.repository.RepositoryModule; import com.walmartlabs.concord.server.role.RoleModule; import com.walmartlabs.concord.server.security.SecurityModule; @@ -89,6 +92,8 @@ public void configure(Binder binder) { binder.bind(Listeners.class).in(SINGLETON); binder.bind(SecureRandom.class).toProvider(SecureRandomProvider.class); + binder.bind(AuthTokenProvider.class).to(ServerAuthTokenProvider.class).in(SINGLETON); + binder.bind(MessageChannelManager.class).in(SINGLETON); binder.bind(DependencyManagerConfiguration.class).toProvider(DependencyManagerConfigurationProvider.class); @@ -110,6 +115,7 @@ public void configure(Binder binder) { binder.install(new WebSocketModule()); bindJaxRsResource(binder, ServerResource.class); + bindJaxRsResource(binder, SystemResource.class); } private static Config loadDefaultConfig() { diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/ConfigurationModule.java b/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/ConfigurationModule.java index 14894dd55a..dd86ab766d 100644 --- a/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/ConfigurationModule.java +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/ConfigurationModule.java @@ -23,7 +23,9 @@ import com.google.inject.Binder; import com.google.inject.Module; import com.typesafe.config.Config; +import com.walmartlabs.concord.common.cfg.OauthTokenConfig; import com.walmartlabs.concord.config.ConfigModule; +import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig; import static com.google.inject.Scopes.SINGLETON; @@ -52,7 +54,9 @@ public void configure(Binder binder) { binder.bind(EnqueueWorkersConfiguration.class).in(SINGLETON); binder.bind(ExternalEventsConfiguration.class).in(SINGLETON); binder.bind(GitConfiguration.class).in(SINGLETON); - binder.bind(GithubConfiguration.class).in(SINGLETON); + binder.bind(OauthTokenConfig.class).to(GitConfiguration.class).in(SINGLETON); + binder.bind(GitHubConfiguration.class).in(SINGLETON); + binder.bind(GitHubAppInstallationConfig.class).to(GitHubConfiguration.class).in(SINGLETON); binder.bind(ImportConfiguration.class).in(SINGLETON); binder.bind(LdapConfiguration.class).in(SINGLETON); binder.bind(LdapGroupSyncConfiguration.class).in(SINGLETON); diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GitConfiguration.java b/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GitConfiguration.java index 07bfd08971..e56acc8a73 100644 --- a/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GitConfiguration.java +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GitConfiguration.java @@ -20,6 +20,8 @@ * ===== */ +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; +import com.walmartlabs.concord.common.cfg.OauthTokenConfig; import com.walmartlabs.concord.config.Config; import org.eclipse.sisu.Nullable; @@ -27,8 +29,9 @@ import java.io.Serializable; import java.time.Duration; import java.util.List; +import java.util.Optional; -public class GitConfiguration implements Serializable { +public class GitConfiguration implements OauthTokenConfig, Serializable { private static final long serialVersionUID = 1L; @@ -38,9 +41,14 @@ public class GitConfiguration implements Serializable { private String oauthToken; @Inject - @Config("git.authorizedGitHosts") + @Config("git.oauthUsername") @Nullable - private List authorizedGitHosts; + private String oauthUsername; + + @Inject + @Config("git.oauthUrlPattern") + @Nullable + private String oauthUrlPattern; @Inject @Config("git.shallowClone") @@ -74,6 +82,14 @@ public class GitConfiguration implements Serializable { @Config("git.sshTimeoutRetryCount") private int sshTimeoutRetryCount; + @Inject + @Config("git.allowedSchemes") + private List allowedSchemes; + + @Inject + @Config("git.systemAuth") + List authConfigs; + public boolean isShallowClone() { return shallowClone; } @@ -90,12 +106,19 @@ public Duration getFetchTimeout() { return fetchTimeout; } - public String getOauthToken() { - return oauthToken; + @Override + public Optional getOauthToken() { + return Optional.ofNullable(oauthToken); } - public List getAuthorizedGitHosts() { - return authorizedGitHosts; + @Override + public Optional getOauthUsername() { + return Optional.ofNullable(oauthUsername); + } + + @Override + public Optional getOauthUrlPattern() { + return Optional.ofNullable(oauthUrlPattern); } public int getHttpLowSpeedLimit() { @@ -113,4 +136,46 @@ public Duration getSshTimeout() { public int getSshTimeoutRetryCount() { return sshTimeoutRetryCount; } + + public List getAllowedSchemes() { return allowedSchemes; } + + public List getSystemAuth() { + return authConfigs.stream() + .map(o -> { + AuthSource type = AuthSource.valueOf(o.getString("type").toUpperCase()); + + return (AuthConfig) switch (type) { + case OAUTH_TOKEN -> OauthConfig.from(o); + }; + }) + .map(AuthConfig::toGitAuth) + .toList(); + } + + enum AuthSource { + OAUTH_TOKEN + } + + public interface AuthConfig { + MappingAuthConfig toGitAuth(); + } + + public record OauthConfig(String urlPattern, String token) implements AuthConfig { + + static OauthConfig from(com.typesafe.config.Config cfg) { + return new OauthConfig( + cfg.getString("urlPattern"), + cfg.getString("token") + ); + } + + @Override + public MappingAuthConfig.OauthAuthConfig toGitAuth() { + return MappingAuthConfig.OauthAuthConfig.builder() + .urlPattern(MappingAuthConfig.assertBaseUrlPattern(this.urlPattern())) + .token(this.token()) + .build(); + } + } + } diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GithubConfiguration.java b/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GitHubConfiguration.java similarity index 53% rename from server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GithubConfiguration.java rename to server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GitHubConfiguration.java index d606e3699b..b55cf3aebf 100644 --- a/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GithubConfiguration.java +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GitHubConfiguration.java @@ -20,12 +20,16 @@ * ===== */ +import com.walmartlabs.concord.common.cfg.MappingAuthConfig; import com.walmartlabs.concord.config.Config; +import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig; import org.eclipse.sisu.Nullable; import javax.inject.Inject; +import java.time.Duration; +import java.util.List; -public class GithubConfiguration { +public class GitHubConfiguration implements GitHubAppInstallationConfig { @Inject @Config("github.secret") @@ -44,6 +48,18 @@ public class GithubConfiguration { @Config("github.disableReposOnDeletedRef") private boolean disableReposOnDeletedRef; + private final GitHubAppInstallationConfig appInstallation; + + @Inject + public GitHubConfiguration(com.typesafe.config.Config config) { + if (config.hasPath("github.appInstallation")) { + var raw = config.getConfig("github.appInstallation"); + this.appInstallation = GitHubAppInstallationConfig.fromConfig(raw); + } else { + this.appInstallation = GitHubAppInstallationConfig.builder().authConfigs(List.of()).build(); + } + } + public String getSecret() { return secret; } @@ -59,4 +75,25 @@ public boolean isLogEvents() { public boolean isDisableReposOnDeletedRef() { return disableReposOnDeletedRef; } + + @Override + public List getAuthConfigs() { + return appInstallation.getAuthConfigs(); + } + + @Override + public Duration getHttpClientTimeout() { + return appInstallation.getHttpClientTimeout(); + } + + @Override + public Duration getSystemAuthCacheDuration() { + return appInstallation.getSystemAuthCacheDuration(); + } + + @Override + public long getSystemAuthCacheMaxWeight() { + return appInstallation.getSystemAuthCacheMaxWeight(); + } + } diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/events/GithubEventResource.java b/server/impl/src/main/java/com/walmartlabs/concord/server/events/GithubEventResource.java index 6186bc1f7f..0d59a021c0 100644 --- a/server/impl/src/main/java/com/walmartlabs/concord/server/events/GithubEventResource.java +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/events/GithubEventResource.java @@ -30,7 +30,7 @@ import com.walmartlabs.concord.server.audit.AuditAction; import com.walmartlabs.concord.server.audit.AuditLog; import com.walmartlabs.concord.server.audit.AuditObject; -import com.walmartlabs.concord.server.cfg.GithubConfiguration; +import com.walmartlabs.concord.server.cfg.GitHubConfiguration; import com.walmartlabs.concord.server.events.github.GithubTriggerProcessor; import com.walmartlabs.concord.server.events.github.Payload; import com.walmartlabs.concord.server.org.triggers.TriggerEntry; @@ -79,7 +79,7 @@ public class GithubEventResource implements Resource { private static final Logger log = LoggerFactory.getLogger(GithubEventResource.class); - private final GithubConfiguration githubCfg; + private final GitHubConfiguration githubCfg; private final TriggerProcessExecutor executor; private final AuditLog auditLog; private final GithubTriggerProcessor processor; @@ -89,7 +89,7 @@ public class GithubEventResource implements Resource { private final Histogram startedProcessesPerEvent; @Inject - public GithubEventResource(GithubConfiguration githubCfg, + public GithubEventResource(GitHubConfiguration githubCfg, TriggerProcessExecutor executor, AuditLog auditLog, GithubTriggerProcessor processor, diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/events/github/GithubTriggerProcessor.java b/server/impl/src/main/java/com/walmartlabs/concord/server/events/github/GithubTriggerProcessor.java index a2234951ab..4828a45de1 100644 --- a/server/impl/src/main/java/com/walmartlabs/concord/server/events/github/GithubTriggerProcessor.java +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/events/github/GithubTriggerProcessor.java @@ -23,7 +23,7 @@ import com.walmartlabs.concord.db.MainDB; import com.walmartlabs.concord.sdk.Constants.Trigger; import com.walmartlabs.concord.sdk.MapUtils; -import com.walmartlabs.concord.server.cfg.GithubConfiguration; +import com.walmartlabs.concord.server.cfg.GitHubConfiguration; import com.walmartlabs.concord.server.events.DefaultEventFilter; import com.walmartlabs.concord.server.org.project.RepositoryDao; import com.walmartlabs.concord.server.org.project.RepositoryEntry; @@ -58,7 +58,7 @@ public class GithubTriggerProcessor { @Inject public GithubTriggerProcessor(Dao dao, Set eventEnrichers, - GithubConfiguration githubCfg) { + GitHubConfiguration githubCfg) { this.dao = dao; this.eventEnrichers = eventEnrichers; this.isDisableReposOnDeletedRef = githubCfg.isDisableReposOnDeletedRef(); diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/events/github/GithubUtils.java b/server/impl/src/main/java/com/walmartlabs/concord/server/events/github/GithubUtils.java index f8c53a40c4..322389f1f6 100644 --- a/server/impl/src/main/java/com/walmartlabs/concord/server/events/github/GithubUtils.java +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/events/github/GithubUtils.java @@ -24,6 +24,7 @@ import com.walmartlabs.concord.sdk.MapUtils; import com.walmartlabs.concord.server.org.triggers.TriggerEntry; +import java.net.URI; import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -99,7 +100,7 @@ public static boolean ignoreEmptyPush(TriggerEntry triggerEntry) { private static String getRepoPath(String repoUrl) { // tests support - if (repoUrl.startsWith("/")) { + if (repoUrl.startsWith("/") || repoUrl.startsWith("file://")) { String[] folders = repoUrl.split("/"); if (folders.length < 2) { return repoUrl; diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/org/secret/SystemResource.java b/server/impl/src/main/java/com/walmartlabs/concord/server/org/secret/SystemResource.java new file mode 100644 index 0000000000..01ef9f18e6 --- /dev/null +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/org/secret/SystemResource.java @@ -0,0 +1,78 @@ +package com.walmartlabs.concord.server.org.secret; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2018 Walmart 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. + * ===== + */ + +import com.walmartlabs.concord.common.ExternalAuthToken; +import com.walmartlabs.concord.common.AuthTokenProvider; +import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException; +import com.walmartlabs.concord.server.sdk.ConcordApplicationException; +import com.walmartlabs.concord.server.sdk.rest.Resource; +import com.walmartlabs.concord.server.sdk.validation.Validate; +import com.walmartlabs.concord.server.security.Permission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import javax.inject.Inject; +import javax.validation.constraints.NotNull; +import javax.ws.rs.*; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.net.URI; + +@Path("/api/v1/system") +@Tag(name = "System") +public class SystemResource implements Resource { + + private final AuthTokenProvider authTokenProvider; + + @Inject + public SystemResource(AuthTokenProvider authTokenProvider) { + this.authTokenProvider = authTokenProvider; + } + + /** + * Refresh repositories by their IDs. + */ + @GET + @Path("/gitauth") + @Produces(MediaType.APPLICATION_JSON) + @Validate + @Operation(description = "Retrieves system-provided auth for give repository URI. Requires externalTokenLookup permission. ", operationId = "getExternalToken") + public ExternalAuthToken getExternalToken(@QueryParam("repoUri") @NotNull URI repoUri) { + assertSystemGitAuthPermission(); + + try { + return authTokenProvider.getToken(repoUri, null) + .orElseThrow(() -> new ConcordApplicationException("No system-provided auth found for the given repository URI: " + repoUri, Response.Status.NOT_FOUND)); + } catch (GitHubAppException.NotFoundException e) { + // repo issue, or app not installed + throw new ConcordApplicationException(e.getMessage(), Response.Status.NOT_FOUND); + } catch (GitHubAppException e) { + throw new ConcordApplicationException(e.getMessage(), Response.Status.BAD_REQUEST); + } + } + + private static void assertSystemGitAuthPermission() { + if (!Permission.EXTERNAL_TOKEN_LOOKUP.isPermitted()) { + throw new ForbiddenException("insufficient privileges to access the resource"); + } + } +} diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/process/ProcessModule.java b/server/impl/src/main/java/com/walmartlabs/concord/server/process/ProcessModule.java index b3247d744d..62615395d9 100644 --- a/server/impl/src/main/java/com/walmartlabs/concord/server/process/ProcessModule.java +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/process/ProcessModule.java @@ -23,7 +23,6 @@ import com.google.inject.Binder; import com.google.inject.Module; import com.walmartlabs.concord.imports.ImportManager; -import com.walmartlabs.concord.process.loader.DelegatingProjectLoader; import com.walmartlabs.concord.process.loader.ProjectLoader; import com.walmartlabs.concord.runtime.v1.ProjectLoaderV1; import com.walmartlabs.concord.runtime.v2.ProjectLoaderV2; diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/repository/RepositoryManager.java b/server/impl/src/main/java/com/walmartlabs/concord/server/repository/RepositoryManager.java index c50f8c5145..5f5ceb67ff 100644 --- a/server/impl/src/main/java/com/walmartlabs/concord/server/repository/RepositoryManager.java +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/repository/RepositoryManager.java @@ -21,6 +21,7 @@ */ import com.fasterxml.jackson.databind.ObjectMapper; +import com.walmartlabs.concord.common.AuthTokenProvider; import com.walmartlabs.concord.common.PathUtils; import com.walmartlabs.concord.dependencymanager.DependencyManager; import com.walmartlabs.concord.repository.*; @@ -38,7 +39,6 @@ import javax.inject.Inject; import java.io.IOException; import java.nio.file.Path; -import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.UUID; @@ -63,11 +63,14 @@ public RepositoryManager(ObjectMapper objectMapper, RepositoryConfiguration repoCfg, ProjectDao projectDao, SecretManager secretManager, - DependencyManager dependencyManager) throws IOException { + DependencyManager dependencyManager, + AuthTokenProvider authProvider) throws IOException { GitClientConfiguration gitCliCfg = GitClientConfiguration.builder() - .oauthToken(gitCfg.getOauthToken()) - .authorizedGitHosts(gitCfg.getAuthorizedGitHosts()) + .oauthToken(gitCfg.getOauthToken()) // TODO remove? authProvider should have the same info now + .oauthUsername(gitCfg.getOauthUsername()) + .oauthUrlPattern(gitCfg.getOauthUrlPattern()) + .allowedSchemes(gitCfg.getAllowedSchemes()) .defaultOperationTimeout(gitCfg.getDefaultOperationTimeout()) .fetchTimeout(gitCfg.getFetchTimeout()) .httpLowSpeedLimit(gitCfg.getHttpLowSpeedLimit()) @@ -76,10 +79,13 @@ public RepositoryManager(ObjectMapper objectMapper, .sshTimeoutRetryCount(gitCfg.getSshTimeoutRetryCount()) .build(); - List providers = Arrays.asList(new ClasspathRepositoryProvider(), new MavenRepositoryProvider(dependencyManager), new GitCliRepositoryProvider(gitCliCfg)); - this.gitCfg = gitCfg; - this.providers = new RepositoryProviders(providers); + this.providers = + new RepositoryProviders(List.of( + new ClasspathRepositoryProvider(), + new MavenRepositoryProvider(dependencyManager), + new GitCliRepositoryProvider(gitCliCfg, authProvider) + )); this.secretManager = secretManager; this.projectDao = projectDao; this.repoCfg = repoCfg; diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProvider.java b/server/impl/src/main/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProvider.java new file mode 100644 index 0000000000..4a369cdb34 --- /dev/null +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProvider.java @@ -0,0 +1,70 @@ +package com.walmartlabs.concord.server.repository; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.codahale.metrics.MetricRegistry; +import com.walmartlabs.concord.common.AuthTokenProvider; +import com.walmartlabs.concord.common.ExternalAuthToken; +import com.walmartlabs.concord.github.appinstallation.GitHubAppInstallation; +import com.walmartlabs.concord.sdk.Secret; +import com.walmartlabs.concord.server.sdk.metrics.WithTimer; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import java.net.URI; +import java.util.List; +import java.util.Optional; + +@Named +@Singleton +public class ServerAuthTokenProvider implements AuthTokenProvider { + + private final List authTokenProviders; + + @Inject + public ServerAuthTokenProvider(GitHubAppInstallation githubProvider, + AuthTokenProvider.OauthTokenProvider oauthTokenProvider, + MetricRegistry metricRegistry) { + this.authTokenProviders = List.of(githubProvider, oauthTokenProvider); + + metricRegistry.gauge("github-token-cache-size", () -> githubProvider::cacheSize); + } + + @Override + public boolean supports(URI repo, @Nullable Secret secret) { + return authTokenProviders.stream() + .anyMatch(p -> p.supports(repo, secret)); + } + + @WithTimer + public Optional getToken(URI repo, @Nullable Secret secret) { + for (var k : authTokenProviders) { + if (k.supports(repo, secret)) { + return k.getToken(repo, secret); + } + } + + return Optional.empty(); + } + +} diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/security/GithubAuthenticatingFilter.java b/server/impl/src/main/java/com/walmartlabs/concord/server/security/GithubAuthenticatingFilter.java index f4cfe93c5d..d7584eff4b 100644 --- a/server/impl/src/main/java/com/walmartlabs/concord/server/security/GithubAuthenticatingFilter.java +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/security/GithubAuthenticatingFilter.java @@ -23,7 +23,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.io.ByteStreams; import com.walmartlabs.concord.server.ConcordObjectMapper; -import com.walmartlabs.concord.server.cfg.GithubConfiguration; +import com.walmartlabs.concord.server.cfg.GitHubConfiguration; import com.walmartlabs.concord.server.org.project.EncryptedProjectValueManager; import com.walmartlabs.concord.server.security.github.GithubKey; import org.apache.shiro.authc.AuthenticationToken; @@ -62,12 +62,12 @@ public class GithubAuthenticatingFilter extends AuthenticatingFilter { public static final String HOOK_PROJECT_ID = "hookProjectId"; public static final String HOOK_REPO_TOKEN = "hookRepoToken"; - private final GithubConfiguration cfg; + private final GitHubConfiguration cfg; private final EncryptedProjectValueManager encryptedValueManager; private final ObjectMapper objectMapper; @Inject - public GithubAuthenticatingFilter(GithubConfiguration cfg, EncryptedProjectValueManager encryptedValueManager, ObjectMapper objectMapper) { + public GithubAuthenticatingFilter(GitHubConfiguration cfg, EncryptedProjectValueManager encryptedValueManager, ObjectMapper objectMapper) { this.cfg = cfg; this.encryptedValueManager = encryptedValueManager; this.objectMapper = objectMapper; diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/security/Permission.java b/server/impl/src/main/java/com/walmartlabs/concord/server/security/Permission.java index 6a384203ae..d26fc5529d 100644 --- a/server/impl/src/main/java/com/walmartlabs/concord/server/security/Permission.java +++ b/server/impl/src/main/java/com/walmartlabs/concord/server/security/Permission.java @@ -46,7 +46,11 @@ public enum Permission { *

* As in {@code com/walmartlabs/concord/server/db/v2.31.0.xml} */ - API_KEY_SPECIFY_VALUE("apiKeySpecifyValue"); + API_KEY_SPECIFY_VALUE("apiKeySpecifyValue"), + /** + * Allows users to access to system-provided git auth tokens. + */ + EXTERNAL_TOKEN_LOOKUP("externalTokenLookup"); private final String key; diff --git a/server/impl/src/test/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProviderTest.java b/server/impl/src/test/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProviderTest.java new file mode 100644 index 0000000000..0600c7bd6f --- /dev/null +++ b/server/impl/src/test/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProviderTest.java @@ -0,0 +1,109 @@ +package com.walmartlabs.concord.server.repository; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart 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. + * ===== + */ + +import com.codahale.metrics.MetricRegistry; +import com.walmartlabs.concord.common.AuthTokenProvider; +import com.walmartlabs.concord.common.ExternalAuthToken; +import com.walmartlabs.concord.github.appinstallation.GitHubAppInstallation; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.URI; +import java.time.OffsetDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ServerAuthTokenProviderTest { + + @Mock + private MetricRegistry metricRegistry; + + @Mock + GitHubAppInstallation ghApp; + + @Mock + AuthTokenProvider.OauthTokenProvider oauthTokenProvider; + + @Test + void testGitHubApp() { + when(ghApp.getToken(any(), any())). + thenReturn(Optional.of(ExternalAuthToken.SimpleToken.builder() + .token("gh-installation-token") + .expiresAt(OffsetDateTime.now().plusMinutes(60)) + .build())); + when(ghApp.supports(any(), any())).thenReturn(true); + + var provider = new ServerAuthTokenProvider(ghApp, oauthTokenProvider, metricRegistry); + + // -- + + assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null)); + var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null); + + // -- + + assertTrue(o.isPresent()); + var result = assertInstanceOf(ExternalAuthToken.class, o.get()); + assertEquals("gh-installation-token", result.token()); + } + + @Test + void testOauth() { + when(oauthTokenProvider.supports(any(), any())).thenReturn(true); + when(oauthTokenProvider.getToken(any(), any())) + .thenReturn(Optional.of(ExternalAuthToken.StaticToken.builder() + .token("oauth-token") + .build())); + + var provider = new ServerAuthTokenProvider(ghApp, oauthTokenProvider, metricRegistry); + + assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null)); + var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null); + + assertTrue(o.isPresent()); + var result = assertInstanceOf(ExternalAuthToken.class, o.get()); + assertEquals("oauth-token", result.token()); + } + + @Test + void testNoAuth() { + when(ghApp.supports(any(), any())).thenReturn(false); + when(oauthTokenProvider.supports(any(), any())).thenReturn(false); + + var provider = new ServerAuthTokenProvider(ghApp, oauthTokenProvider, metricRegistry); + + assertFalse(provider.supports(URI.create("https://github.local/owner/repo.git"), null)); + var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null); + + assertFalse(o.isPresent()); + } + +} diff --git a/targetplatform/pom.xml b/targetplatform/pom.xml index e4d3cb14d0..46e4e2043c 100644 --- a/targetplatform/pom.xml +++ b/targetplatform/pom.xml @@ -147,6 +147,11 @@ concord-common ${project.version} + + com.walmartlabs.concord + concord-github-app-installation + ${project.version} + com.walmartlabs.concord concord-server