diff options
author | Antoine Vigneau <antoine.vigneau@sonarsource.com> | 2023-06-02 14:14:30 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-06-05 20:02:48 +0000 |
commit | c1966338ee963dbd6e6f636635f390cfeb334af5 (patch) | |
tree | 0db158f7b2229db3cd33388f82b2664a5329b729 /server/sonar-alm-client | |
parent | b2e7f33ced83a025961195ba5065986e94aae9f0 (diff) | |
download | sonarqube-c1966338ee963dbd6e6f636635f390cfeb334af5.tar.gz sonarqube-c1966338ee963dbd6e6f636635f390cfeb334af5.zip |
SONAR-19346 GitHub config check available in Community Edition
Diffstat (limited to 'server/sonar-alm-client')
8 files changed, 759 insertions, 2 deletions
diff --git a/server/sonar-alm-client/build.gradle b/server/sonar-alm-client/build.gradle index a15661a6cba..fa06b389318 100644 --- a/server/sonar-alm-client/build.gradle +++ b/server/sonar-alm-client/build.gradle @@ -11,6 +11,7 @@ dependencies { api 'com.auth0:java-jwt' api 'org.bouncycastle:bcpkix-jdk15on:1.70' api 'org.sonarsource.api.plugin:sonar-plugin-api' + api project(':server:sonar-auth-github') testImplementation project(':sonar-plugin-api-impl') diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java index 7446d304ec6..11848ba0f4a 100644 --- a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java @@ -26,6 +26,7 @@ import javax.annotation.CheckForNull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import org.sonar.alm.client.github.config.GithubAppConfiguration; +import org.sonar.alm.client.github.config.GithubAppInstallation; import org.sonar.alm.client.github.security.AccessToken; import org.sonar.alm.client.github.security.UserAccessToken; import org.sonar.api.server.ServerSide; @@ -43,12 +44,20 @@ public interface GithubApplicationClient { */ UserAccessToken createUserAccessToken(String appUrl, String clientId, String clientSecret, String code); + GithubBinding.GsonApp getApp(GithubAppConfiguration githubAppConfiguration); + /** * Lists all the organizations accessible to the access token provided. */ Organizations listOrganizations(String appUrl, AccessToken accessToken, int page, int pageSize); /** + * Retrieve all installations of the GitHub app, filtering out the ones not whitelisted in GitHub Settings (if set) + * @throws IllegalArgumentException if one of the arguments is invalid (for example, wrong private key) + */ + List<GithubAppInstallation> getWhitelistedGithubAppInstallations(GithubAppConfiguration githubAppConfiguration); + + /** * Lists all the repositories of the provided organization accessible to the access token provided. */ Repositories listRepositories(String appUrl, AccessToken accessToken, String organization, @Nullable String query, int page, int pageSize); diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java index eb7a4718a34..4b771efee8e 100644 --- a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java @@ -29,6 +29,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nullable; import org.sonar.alm.client.github.GithubApplicationHttpClient.GetResponse; @@ -36,6 +37,7 @@ import org.sonar.alm.client.github.GithubBinding.GsonGithubRepository; import org.sonar.alm.client.github.GithubBinding.GsonInstallations; import org.sonar.alm.client.github.GithubBinding.GsonRepositorySearch; import org.sonar.alm.client.github.config.GithubAppConfiguration; +import org.sonar.alm.client.github.config.GithubAppInstallation; import org.sonar.alm.client.github.security.AccessToken; import org.sonar.alm.client.github.security.AppToken; import org.sonar.alm.client.github.security.GithubAppSecurity; @@ -44,10 +46,14 @@ import org.sonar.alm.client.gitlab.GsonApp; import org.sonar.api.internal.apachecommons.lang.StringUtils; import org.sonar.api.utils.log.Logger; import org.sonar.api.utils.log.Loggers; +import org.sonar.auth.github.GitHubSettings; +import org.sonar.server.exceptions.ServerException; +import org.sonarqube.ws.client.HttpException; import static com.google.common.base.Preconditions.checkArgument; import static java.lang.String.format; import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; import static java.net.HttpURLConnection.HTTP_OK; import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; @@ -61,10 +67,12 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { protected final GithubApplicationHttpClient appHttpClient; protected final GithubAppSecurity appSecurity; + private final GitHubSettings gitHubSettings; - public GithubApplicationClientImpl(GithubApplicationHttpClient appHttpClient, GithubAppSecurity appSecurity) { + public GithubApplicationClientImpl(GithubApplicationHttpClient appHttpClient, GithubAppSecurity appSecurity, GitHubSettings gitHubSettings) { this.appHttpClient = appHttpClient; this.appSecurity = appSecurity; + this.gitHubSettings = gitHubSettings; } private static void checkPageArgs(int page, int pageSize) { @@ -161,6 +169,53 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { } @Override + public List<GithubAppInstallation> getWhitelistedGithubAppInstallations(GithubAppConfiguration githubAppConfiguration) { + GithubBinding.GsonInstallation[] gsonAppInstallations = fetchAppInstallationsFromGithub(githubAppConfiguration); + Set<String> allowedOrganizations = gitHubSettings.getOrganizations(); + return convertToGithubAppInstallationAndFilterWhitelisted(gsonAppInstallations, allowedOrganizations); + } + + private static List<GithubAppInstallation> convertToGithubAppInstallationAndFilterWhitelisted(GithubBinding.GsonInstallation[] gsonAppInstallations, + Set<String> allowedOrganizations) { + return Arrays.stream(gsonAppInstallations) + .filter(appInstallation -> appInstallation.getAccount().getType().equalsIgnoreCase("Organization")) + .map(GithubApplicationClientImpl::toGithubAppInstallation) + .filter(appInstallation -> isOrganizationWhiteListed(allowedOrganizations, appInstallation.organizationName())) + .toList(); + } + + private static GithubAppInstallation toGithubAppInstallation(GithubBinding.GsonInstallation gsonInstallation) { + return new GithubAppInstallation( + Long.toString(gsonInstallation.getId()), + gsonInstallation.getAccount().getLogin(), + gsonInstallation.getPermissions(), + org.apache.commons.lang.StringUtils.isNotEmpty(gsonInstallation.getSuspendedAt())); + } + + private static boolean isOrganizationWhiteListed(Set<String> allowedOrganizations, String organizationName) { + return allowedOrganizations.isEmpty() || allowedOrganizations.contains(organizationName); + } + + private GithubBinding.GsonInstallation[] fetchAppInstallationsFromGithub(GithubAppConfiguration githubAppConfiguration) { + AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey()); + String endpoint = "/app/installations"; + return get(githubAppConfiguration.getApiEndpoint(), appToken, endpoint, + GithubBinding.GsonInstallation[].class).orElseThrow( + () -> new IllegalStateException("An error occurred when retrieving your GitHup App installations. " + + "It might be related to your GitHub App configuration or a connectivity problem.")); + } + + protected <T> Optional<T> get(String baseUrl, AccessToken token, String endPoint, Class<T> gsonClass) { + try { + GetResponse response = appHttpClient.get(baseUrl, token, endPoint); + return handleResponse(response, endPoint, gsonClass); + } catch (Exception e) { + LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + endPoint, e); + return Optional.empty(); + } + } + + @Override public Repositories listRepositories(String appUrl, AccessToken accessToken, String organization, @Nullable String query, int page, int pageSize) { checkPageArgs(page, pageSize); String searchQuery = "fork:true+org:" + organization; @@ -242,6 +297,25 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { } } + @Override + public GithubBinding.GsonApp getApp(GithubAppConfiguration githubAppConfiguration) { + AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey()); + String endpoint = "/app"; + return getOrThrowIfNotHttpOk(githubAppConfiguration.getApiEndpoint(), appToken, endpoint, GithubBinding.GsonApp.class); + } + + private <T> T getOrThrowIfNotHttpOk(String baseUrl, AccessToken token, String endPoint, Class<T> gsonClass) { + try { + GetResponse response = appHttpClient.get(baseUrl, token, endPoint); + if (response.getCode() != HTTP_OK) { + throw new HttpException(baseUrl + endPoint, response.getCode(), response.getContent().orElse("")); + } + return handleResponse(response, endPoint, gsonClass).orElseThrow(() -> new ServerException(HTTP_INTERNAL_ERROR, "Http response withuot content")); + } catch (IOException e) { + throw new ServerException(HTTP_INTERNAL_ERROR, e.getMessage()); + } + } + protected static <T> Optional<T> handleResponse(GithubApplicationHttpClient.Response response, String endPoint, Class<T> gsonClass) { try { diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/ConfigCheckResult.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/ConfigCheckResult.java new file mode 100644 index 00000000000..1af06e2d4d1 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/ConfigCheckResult.java @@ -0,0 +1,51 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.github.config; + +import com.google.gson.annotations.SerializedName; +import java.util.List; +import javax.annotation.Nullable; + +public record ConfigCheckResult(@SerializedName("application") ApplicationStatus application, @SerializedName("installations") List<InstallationStatus> installations) { + + public record ConfigStatus(@SerializedName("status") String status, @Nullable @SerializedName("errorMessage") String errorMessage) { + + public static final String SUCCESS_STATUS = "SUCCESS"; + public static final String FAILED_STATUS = "FAILED"; + + public static final ConfigStatus SUCCESS = new ConfigStatus(SUCCESS_STATUS); + + public ConfigStatus(String status) { + this(status, null); + } + + public static ConfigStatus failed(String errorMessage) { + return new ConfigStatus(FAILED_STATUS, errorMessage); + } + + } + + public record ApplicationStatus(@SerializedName("jit") ConfigStatus jit, @SerializedName("autoProvisioning") ConfigStatus autoProvisioning) { + } + + public record InstallationStatus(@SerializedName("organization") String organization, @SerializedName("jit") ConfigStatus jit, + @SerializedName("autoProvisioning") ConfigStatus autoProvisioning) { + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppInstallation.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppInstallation.java new file mode 100644 index 00000000000..5f59f48c617 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppInstallation.java @@ -0,0 +1,24 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.github.config; + +import org.sonar.alm.client.github.GithubBinding; + +public record GithubAppInstallation(String installationId, String organizationName, GithubBinding.Permissions permissions, boolean isSuspended) {} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidator.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidator.java new file mode 100644 index 00000000000..3e6bb8f001f --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidator.java @@ -0,0 +1,167 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.github.config; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.sonar.alm.client.github.GithubApplicationClient; +import org.sonar.alm.client.github.GithubBinding.Permissions; +import org.sonar.api.ce.ComputeEngineSide; +import org.sonar.api.server.ServerSide; +import org.sonar.auth.github.GitHubSettings; +import org.sonarqube.ws.client.HttpException; + +import static java.lang.Long.parseLong; +import static org.sonar.alm.client.github.GithubBinding.GsonApp; +import static org.sonar.alm.client.github.config.ConfigCheckResult.ApplicationStatus; +import static org.sonar.alm.client.github.config.ConfigCheckResult.ConfigStatus; +import static org.sonar.alm.client.github.config.ConfigCheckResult.InstallationStatus; + +@ServerSide +@ComputeEngineSide +public class GithubProvisioningConfigValidator { + + private static final ConfigStatus APP_NOT_FOUND_STATUS = ConfigStatus.failed("Github App not found"); + private static final String MEMBERS_PERMISSION = "Organization permissions -> Members"; + + private static final String EMAILS_PERMISSION = "Account permissions -> Email addresses"; + + private static final ConfigStatus INVALID_APP_CONFIG_STATUS = ConfigStatus.failed("The GitHub App configuration is not complete."); + private static final ConfigStatus INVALID_APP_ID_STATUS = ConfigStatus.failed("GitHub App ID must be a number."); + private static final ConfigStatus SUSPENDED_INSTALLATION_STATUS = ConfigStatus.failed("Installation suspended"); + private static final ConfigStatus NO_INSTALLATION_FOUND_STATUS = ConfigStatus.failed( + "The GitHub App is not installed on any organizations or the organization is not white-listed."); + private static final ConfigCheckResult NO_INSTALLATIONS_RESULT = new ConfigCheckResult( + new ApplicationStatus( + NO_INSTALLATION_FOUND_STATUS, + NO_INSTALLATION_FOUND_STATUS), + List.of()); + + private final GithubApplicationClient githubClient; + private final GitHubSettings gitHubSettings; + + public GithubProvisioningConfigValidator(GithubApplicationClient githubClient, GitHubSettings gitHubSettings) { + this.githubClient = githubClient; + this.gitHubSettings = gitHubSettings; + } + + public ConfigCheckResult checkConfig() { + Optional<Long> appId = getAppId(); + if (appId.isEmpty()) { + return failedApplicationStatus(INVALID_APP_ID_STATUS); + } + GithubAppConfiguration githubAppConfiguration = new GithubAppConfiguration(appId.get(), gitHubSettings.privateKey(), gitHubSettings.apiURLOrDefault()); + return checkConfig(githubAppConfiguration); + } + + private Optional<Long> getAppId() { + try { + return Optional.of(parseLong(gitHubSettings.appId())); + } catch (NumberFormatException numberFormatException) { + return Optional.empty(); + } + } + + public ConfigCheckResult checkConfig(GithubAppConfiguration githubAppConfiguration) { + if (!githubAppConfiguration.isComplete()) { + return failedApplicationStatus(INVALID_APP_CONFIG_STATUS); + } + + try { + GsonApp app = githubClient.getApp(githubAppConfiguration); + return checkNonEmptyConfig(githubAppConfiguration, app); + } catch (HttpException e) { + return failedApplicationStatus( + ConfigStatus.failed("Error response from GitHub: " + e.getMessage())); + } + } + + private static ConfigCheckResult failedApplicationStatus(ConfigStatus configStatus) { + return new ConfigCheckResult(new ApplicationStatus(configStatus, configStatus), List.of()); + } + + private ConfigCheckResult checkNonEmptyConfig(GithubAppConfiguration githubAppConfiguration, GsonApp app) { + ApplicationStatus appStatus = checkNonEmptyAppConfig(app); + List<InstallationStatus> installations = checkInstallations(githubAppConfiguration, appStatus); + if (installations.isEmpty()) { + return NO_INSTALLATIONS_RESULT; + } + return new ConfigCheckResult( + appStatus, + installations); + } + + private static ApplicationStatus checkNonEmptyAppConfig(GsonApp app) { + return new ApplicationStatus( + jitAppConfigStatus(app.getPermissions()), + autoProvisioningAppConfigStatus(app.getPermissions())); + } + + private static ConfigStatus jitAppConfigStatus(Permissions permissions) { + if (permissions.getEmails() == null) { + return failedStatus(List.of(EMAILS_PERMISSION)); + } + return ConfigStatus.SUCCESS; + } + + private static ConfigStatus autoProvisioningAppConfigStatus(Permissions permissions) { + List<String> missingPermissions = new ArrayList<>(); + if (permissions.getEmails() == null) { + missingPermissions.add(EMAILS_PERMISSION); + } + if (permissions.getMembers() == null) { + missingPermissions.add(MEMBERS_PERMISSION); + } + if (missingPermissions.isEmpty()) { + return ConfigStatus.SUCCESS; + } + return failedStatus(missingPermissions); + } + + private static ConfigStatus failedStatus(List<String> missingPermissions) { + return ConfigStatus.failed("Missing permissions: " + String.join(",", missingPermissions)); + } + + private List<InstallationStatus> checkInstallations(GithubAppConfiguration githubAppConfiguration, ApplicationStatus appStatus) { + return githubClient.getWhitelistedGithubAppInstallations(githubAppConfiguration) + .stream() + .map(installation -> statusForInstallation(installation, appStatus)) + .toList(); + } + + private static InstallationStatus statusForInstallation(GithubAppInstallation installation, ApplicationStatus appStatus) { + if (installation.isSuspended()) { + return new InstallationStatus(installation.organizationName(), SUSPENDED_INSTALLATION_STATUS, SUSPENDED_INSTALLATION_STATUS); + } + return new InstallationStatus( + installation.organizationName(), + appStatus.jit(), + autoProvisioningInstallationConfigStatus(installation.permissions())); + } + + private static ConfigStatus autoProvisioningInstallationConfigStatus(Permissions permissions) { + if (permissions.getMembers() == null) { + return failedStatus(List.of(MEMBERS_PERMISSION)); + } + return ConfigStatus.SUCCESS; + } + +} diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java index fbe8aa95ca0..2c5e4a4726b 100644 --- a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java @@ -23,19 +23,24 @@ import com.tngtech.java.junit.dataprovider.DataProvider; import com.tngtech.java.junit.dataprovider.DataProviderRunner; import com.tngtech.java.junit.dataprovider.UseDataProvider; import java.io.IOException; +import java.util.List; import java.util.Optional; +import java.util.Set; import javax.annotation.Nullable; import org.junit.Before; import org.junit.ClassRule; import org.junit.Test; import org.junit.runner.RunWith; import org.sonar.alm.client.github.config.GithubAppConfiguration; +import org.sonar.alm.client.github.config.GithubAppInstallation; import org.sonar.alm.client.github.security.AccessToken; import org.sonar.alm.client.github.security.AppToken; import org.sonar.alm.client.github.security.GithubAppSecurity; import org.sonar.alm.client.github.security.UserAccessToken; import org.sonar.api.testfixtures.log.LogTester; import org.sonar.api.utils.log.LoggerLevel; +import org.sonar.auth.github.GitHubSettings; +import org.sonarqube.ws.client.HttpException; import static java.net.HttpURLConnection.HTTP_FORBIDDEN; import static java.net.HttpURLConnection.HTTP_NOT_FOUND; @@ -53,12 +58,43 @@ import static org.mockito.Mockito.when; @RunWith(DataProviderRunner.class) public class GithubApplicationClientImplTest { + private static final String APP_JWT_TOKEN = "APP_TOKEN_JWT"; + private static final String PAYLOAD_2_ORGS = """ + [ + { + "id": 1, + "account": { + "login": "org1", + "type": "Organization" + }, + "target_type": "Organization", + "permissions": { + "members": "read", + "metadata": "read" + }, + "suspended_at": "2023-05-30T08:40:55Z" + }, + { + "id": 2, + "account": { + "login": "org2", + "type": "Organization" + }, + "target_type": "Organization", + "permissions": { + "members": "read", + "metadata": "read" + } + } + ]"""; + @ClassRule public static LogTester logTester = new LogTester().setLevel(LoggerLevel.WARN); private GithubApplicationHttpClientImpl httpClient = mock(GithubApplicationHttpClientImpl.class); private GithubAppSecurity appSecurity = mock(GithubAppSecurity.class); private GithubAppConfiguration githubAppConfiguration = mock(GithubAppConfiguration.class); + private GitHubSettings gitHubSettings = mock(GitHubSettings.class); private GithubApplicationClient underTest; private String appUrl = "Any URL"; @@ -66,7 +102,7 @@ public class GithubApplicationClientImplTest { @Before public void setup() { when(githubAppConfiguration.getApiEndpoint()).thenReturn(appUrl); - underTest = new GithubApplicationClientImpl(httpClient, appSecurity); + underTest = new GithubApplicationClientImpl(httpClient, appSecurity, gitHubSettings); logTester.clear(); } @@ -242,6 +278,31 @@ public class GithubApplicationClientImplTest { verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"); } + @Test + public void getApp_returns_id() throws IOException { + AppToken appToken = new AppToken(APP_JWT_TOKEN); + when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); + when(httpClient.get(appUrl, appToken, "/app")) + .thenReturn(new OkGetResponse("{\"installations_count\": 2}")); + + assertThat(underTest.getApp(githubAppConfiguration).getInstallationsCount()).isEqualTo(2L); + } + + @Test + public void getApp_whenStatusCodeIsNotOk_shouldThrowHttpException() throws IOException { + AppToken appToken = new AppToken(APP_JWT_TOKEN); + when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); + when(httpClient.get(appUrl, appToken, "/app")) + .thenReturn(new ErrorGetResponse(418, "I'm a teapot")); + + assertThatThrownBy(() -> underTest.getApp(githubAppConfiguration)) + .isInstanceOfSatisfying(HttpException.class, httpException -> { + assertThat(httpException.code()).isEqualTo(418); + assertThat(httpException.url()).isEqualTo("Any URL/app"); + assertThat(httpException.content()).isEqualTo("I'm a teapot"); + }); + } + @DataProvider public static Object[][] githubServers() { return new Object[][] { @@ -393,6 +454,82 @@ public class GithubApplicationClientImplTest { } @Test + public void getWhitelistedGithubAppInstallations_whenWhitelistNotSpecified_doesNotFilter() throws IOException { + List<GithubAppInstallation> allOrgInstallations = getGithubAppInstallationsFromGithubResponse(PAYLOAD_2_ORGS); + assertOrgDeserialization(allOrgInstallations); + } + + private static void assertOrgDeserialization(List<GithubAppInstallation> orgs) { + GithubAppInstallation org1 = orgs.get(0); + assertThat(org1.installationId()).isEqualTo("1"); + assertThat(org1.organizationName()).isEqualTo("org1"); + assertThat(org1.permissions().getMembers()).isEqualTo("read"); + assertThat(org1.isSuspended()).isTrue(); + + GithubAppInstallation org2 = orgs.get(1); + assertThat(org2.installationId()).isEqualTo("2"); + assertThat(org2.organizationName()).isEqualTo("org2"); + assertThat(org2.permissions().getMembers()).isEqualTo("read"); + assertThat(org2.isSuspended()).isFalse(); + } + + @Test + public void getWhitelistedGithubAppInstallations_whenWhitelistSpecified_filtersWhitelistedOrgs() throws IOException { + when(gitHubSettings.getOrganizations()).thenReturn(Set.of("org2")); + List<GithubAppInstallation> orgInstallations = getGithubAppInstallationsFromGithubResponse(PAYLOAD_2_ORGS); + assertThat(orgInstallations) + .hasSize(1) + .extracting(GithubAppInstallation::organizationName) + .containsExactlyInAnyOrder("org2"); + } + + @Test + public void getWhitelistedGithubAppInstallations_whenEmptyResponse_shouldReturnEmpty() throws IOException { + List<GithubAppInstallation> allOrgInstallations = getGithubAppInstallationsFromGithubResponse("[]"); + assertThat(allOrgInstallations).isEmpty(); + } + + @Test + public void getWhitelistedGithubAppInstallations_whenNoOrganization_shouldReturnEmpty() throws IOException { + List<GithubAppInstallation> allOrgInstallations = getGithubAppInstallationsFromGithubResponse(""" + [ + { + "id": 1, + "account": { + "login": "user1", + "type": "User" + }, + "target_type": "User", + "permissions": { + "metadata": "read" + } + } + ]"""); + assertThat(allOrgInstallations).isEmpty(); + } + + private List<GithubAppInstallation> getGithubAppInstallationsFromGithubResponse(String content) throws IOException { + AppToken appToken = new AppToken(APP_JWT_TOKEN); + when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); + when(httpClient.get(appUrl, appToken, "/app/installations")) + .thenReturn(new OkGetResponse(content)); + return underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration); + } + + @Test + public void getWhitelistedGithubAppInstallations_whenGithubReturnsError_shouldThrow() throws IOException { + AppToken appToken = new AppToken(APP_JWT_TOKEN); + when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); + when(httpClient.get(appUrl, appToken, "/app/installations")) + .thenReturn(new ErrorGetResponse()); + + assertThatThrownBy(() -> underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("An error occurred when retrieving your GitHup App installations. " + + "It might be related to your GitHub App configuration or a connectivity problem."); + } + + @Test public void listRepositories_fail_on_failure() throws IOException { String appUrl = "https://github.sonarsource.com"; AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10)); diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidatorTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidatorTest.java new file mode 100644 index 00000000000..e743102c734 --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidatorTest.java @@ -0,0 +1,294 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.github.config; + +import java.util.Arrays; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.sonar.alm.client.github.GithubApplicationClient; +import org.sonar.alm.client.github.GithubBinding.GsonApp; +import org.sonar.alm.client.github.GithubBinding.Permissions; +import org.sonar.auth.github.GitHubSettings; +import org.sonarqube.ws.client.HttpException; + +import static java.lang.Long.parseLong; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.sonar.alm.client.github.config.ConfigCheckResult.ConfigStatus; +import static org.sonar.alm.client.github.config.ConfigCheckResult.InstallationStatus; + +@RunWith(MockitoJUnitRunner.class) +public class GithubProvisioningConfigValidatorTest { + + private static final String SUCCESS_STATUS = "SUCCESS"; + private static final String GITHUB_CALL_FAILED = "Error response from GitHub: GitHub call failed."; + private static final String INVALID_APP_ID_STATUS = "GitHub App ID must be a number."; + private static final String INCOMPLETE_APP_CONFIG_STATUS = "The GitHub App configuration is not complete."; + private static final String MISSING_EMAIL_PERMISSION = "Missing permissions: Account permissions -> Email addresses"; + private static final String MISSING_MEMBERS_PERMISSION = "Missing permissions: Organization permissions -> Members"; + private static final String MISSING_EMAILS_AND_MEMBERS_PERMISSION = "Missing permissions: Account permissions -> Email addresses,Organization permissions -> Members"; + private static final String NO_INSTALLATIONS_STATUS = "The GitHub App is not installed on any organizations or the organization is not white-listed."; + private static final String SUSPENDED_INSTALLATION = "Installation suspended"; + + private static final ConfigStatus SUCCESS_CHECK = new ConfigStatus(SUCCESS_STATUS, null); + public static final String APP_ID = "1"; + public static final String PRIVATE_KEY = "secret"; + public static final String URL = "url"; + @Mock + private GithubApplicationClient githubClient; + + @Mock + private GitHubSettings gitHubSettings; + + @InjectMocks + private GithubProvisioningConfigValidator configValidator; + + @Test + public void checkConfig_whenAppIdIsNull_shouldReturnFailedAppCheck() { + when(gitHubSettings.appId()).thenReturn(null); + + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(INVALID_APP_ID_STATUS)); + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.failed(INVALID_APP_ID_STATUS)); + assertThat(checkResult.installations()).isEmpty(); + } + @Test + public void checkConfig_whenAppIdNotValid_shouldReturnFailedAppCheck() { + when(gitHubSettings.appId()).thenReturn("not a number"); + + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(INVALID_APP_ID_STATUS)); + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.failed(INVALID_APP_ID_STATUS)); + assertThat(checkResult.installations()).isEmpty(); + } + + @Test + public void checkConfig_whenGithubAppConfigurationNotComplete_shouldReturnFailedAppCheck() { + when(gitHubSettings.appId()).thenReturn(APP_ID); + + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(INCOMPLETE_APP_CONFIG_STATUS)); + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.failed(INCOMPLETE_APP_CONFIG_STATUS)); + assertThat(checkResult.installations()).isEmpty(); + } + + @Test + public void checkConfig_whenErrorWhileFetchingTheApp_shouldReturnFailedAppCheck() { + mockGithubConfiguration(); + ArgumentCaptor<GithubAppConfiguration> appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + + HttpException httpException = mock(HttpException.class); + when(httpException.getMessage()).thenReturn("GitHub call failed."); + + when(githubClient.getApp(appConfigurationCaptor.capture())).thenThrow(httpException); + + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(GITHUB_CALL_FAILED)); + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.failed(GITHUB_CALL_FAILED)); + assertThat(checkResult.installations()).isEmpty(); + } + + @Test + public void checkConfig_whenAppDoesntHaveEmailsPermissions_shouldReturnFailedAppJitCheck() { + mockGithubConfiguration(); + ArgumentCaptor<GithubAppConfiguration> appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + GsonApp githubApp = mockGithubApp(appConfigurationCaptor); + + when(githubApp.getPermissions()).thenReturn(new Permissions()); + mockOrganizationsWithoutPermissions(appConfigurationCaptor, "org1", "org2"); + + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(MISSING_EMAILS_AND_MEMBERS_PERMISSION)); + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.failed(MISSING_EMAIL_PERMISSION)); + assertThat(checkResult.installations()).hasSize(2); + assertThat(checkResult.installations()) + .extracting(InstallationStatus::jit, InstallationStatus::autoProvisioning) + .containsExactly( + tuple(ConfigStatus.failed(MISSING_EMAIL_PERMISSION), ConfigStatus.failed(MISSING_MEMBERS_PERMISSION)), + tuple(ConfigStatus.failed(MISSING_EMAIL_PERMISSION), ConfigStatus.failed(MISSING_MEMBERS_PERMISSION))); + verifyAppConfiguration(appConfigurationCaptor.getValue()); + + } + + @Test + public void checkConfig_whenAppDoesntHaveMembersPermissions_shouldReturnFailedAppAutoProvisioningCheck() { + mockGithubConfiguration(); + ArgumentCaptor<GithubAppConfiguration> appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + + GsonApp githubApp = mockGithubApp(appConfigurationCaptor); + when(githubApp.getPermissions()).thenReturn(new Permissions(null, null, "read")); + mockOrganizations(appConfigurationCaptor, "org1", "org2"); + + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.SUCCESS); + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(MISSING_MEMBERS_PERMISSION)); + assertThat(checkResult.installations()).hasSize(2); + verifyAppConfiguration(appConfigurationCaptor.getValue()); + } + + @Test + public void checkConfig_whenNoInstallationsAreReturned_shouldReturnFailedAppAutoProvisioningCheck() { + mockGithubConfiguration(); + ArgumentCaptor<GithubAppConfiguration> appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + mockGithubAppWithValidConfig(appConfigurationCaptor); + + mockOrganizationsWithoutPermissions(appConfigurationCaptor); + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.failed(NO_INSTALLATIONS_STATUS)); + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(NO_INSTALLATIONS_STATUS)); + assertThat(checkResult.installations()).isEmpty(); + + verifyAppConfiguration(appConfigurationCaptor.getValue()); + + } + + @Test + public void checkConfig_whenInstallationsDoesntHaveMembersPermissions_shouldReturnFailedAppAutoProvisioningCheck() { + mockGithubConfiguration(); + ArgumentCaptor<GithubAppConfiguration> appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + mockGithubAppWithValidConfig(appConfigurationCaptor); + + mockOrganizationsWithoutPermissions(appConfigurationCaptor, "org1"); + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertSuccessfulAppConfig(checkResult); + assertThat(checkResult.installations()) + .extracting(InstallationStatus::organization, InstallationStatus::autoProvisioning) + .containsExactly(tuple("org1", ConfigStatus.failed(MISSING_MEMBERS_PERMISSION))); + verifyAppConfiguration(appConfigurationCaptor.getValue()); + + } + + @Test + public void checkConfig_whenInstallationSuspended_shouldReturnFailedInstallationAutoProvisioningCheck() { + mockGithubConfiguration(); + ArgumentCaptor<GithubAppConfiguration> appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + mockGithubAppWithValidConfig(appConfigurationCaptor); + + mockSuspendedOrganizations("org1"); + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertSuccessfulAppConfig(checkResult); + assertThat(checkResult.installations()) + .extracting(InstallationStatus::organization, InstallationStatus::autoProvisioning) + .containsExactly(tuple("org1", ConfigStatus.failed(SUSPENDED_INSTALLATION))); + verify(githubClient).getWhitelistedGithubAppInstallations(appConfigurationCaptor.capture()); + verifyAppConfiguration(appConfigurationCaptor.getValue()); + } + + @Test + public void checkConfig_whenAllPermissionsAreCorrect_shouldReturnSuccessfulCheck() { + mockGithubConfiguration(); + ArgumentCaptor<GithubAppConfiguration> appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + mockGithubAppWithValidConfig(appConfigurationCaptor); + + mockOrganizations(appConfigurationCaptor, "org1", "org2"); + ConfigCheckResult checkResult = configValidator.checkConfig(); + assertSuccessfulAppConfig(checkResult); + + assertThat(checkResult.installations()) + .extracting(InstallationStatus::organization, InstallationStatus::autoProvisioning) + .containsExactlyInAnyOrder( + tuple("org1", SUCCESS_CHECK), + tuple("org2", ConfigStatus.failed(MISSING_MEMBERS_PERMISSION))); + verifyAppConfiguration(appConfigurationCaptor.getValue()); + + } + + private void mockGithubConfiguration() { + when(gitHubSettings.appId()).thenReturn(APP_ID); + when(gitHubSettings.privateKey()).thenReturn(PRIVATE_KEY); + when(gitHubSettings.apiURLOrDefault()).thenReturn(URL); + } + + private void verifyAppConfiguration(GithubAppConfiguration appConfiguration) { + assertThat(appConfiguration.getId()).isEqualTo(parseLong(APP_ID)); + assertThat(appConfiguration.getPrivateKey()).isEqualTo(PRIVATE_KEY); + assertThat(appConfiguration.getApiEndpoint()).isEqualTo(URL); + } + + private GsonApp mockGithubApp(ArgumentCaptor<GithubAppConfiguration> appConfigurationCaptor) { + GsonApp githubApp = mock(GsonApp.class); + when(githubClient.getApp(appConfigurationCaptor.capture())).thenReturn(githubApp); + return githubApp; + } + + private GsonApp mockGithubAppWithValidConfig(ArgumentCaptor<GithubAppConfiguration> appConfigurationCaptor) { + GsonApp githubApp = mock(GsonApp.class); + when(githubClient.getApp(appConfigurationCaptor.capture())).thenReturn(githubApp); + when(githubApp.getPermissions()).thenReturn(new Permissions(null, "read", "read")); + + return githubApp; + } + + private static void assertSuccessfulAppConfig(ConfigCheckResult checkResult) { + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.SUCCESS); + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.SUCCESS); + } + + private void mockOrganizationsWithoutPermissions(ArgumentCaptor<GithubAppConfiguration> appConfigurationCaptor, String... organizations) { + + List<GithubAppInstallation> installations = Arrays.stream(organizations).map(GithubProvisioningConfigValidatorTest::mockInstallation).toList(); + when(githubClient.getWhitelistedGithubAppInstallations(appConfigurationCaptor.capture())).thenReturn(installations); + + } + + private void mockSuspendedOrganizations(String orgName) { + GithubAppInstallation installation = new GithubAppInstallation(null, orgName, null, true); + when(githubClient.getWhitelistedGithubAppInstallations(any())).thenReturn(List.of(installation)); + } + + private static GithubAppInstallation mockInstallation(String org) { + GithubAppInstallation installation = mock(GithubAppInstallation.class); + when(installation.organizationName()).thenReturn(org); + when(installation.permissions()).thenReturn(mock(Permissions.class)); + return installation; + } + + private static GithubAppInstallation mockInstallationWithMembersPermission(String org) { + GithubAppInstallation installation = mockInstallation(org); + when(installation.permissions()).thenReturn(new Permissions(null, "read", "read")); + return installation; + } + + private void mockOrganizations(ArgumentCaptor<GithubAppConfiguration> appConfigurationCaptor, String orgWithMembersPermission, String orgWithoutMembersPermission) { + List<GithubAppInstallation> installations = List.of( + mockInstallationWithMembersPermission(orgWithMembersPermission), + mockInstallation(orgWithoutMembersPermission)); + when(githubClient.getWhitelistedGithubAppInstallations(appConfigurationCaptor.capture())).thenReturn(installations); + } + +} |