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')
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;
*/
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.
*/
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;
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;
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;
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) {
}
}
+ @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);
}
}
+ @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 {
--- /dev/null
+/*
+ * 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) {
+ }
+}
--- /dev/null
+/*
+ * 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) {}
--- /dev/null
+/*
+ * 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;
+ }
+
+}
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;
@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";
@Before
public void setup() {
when(githubAppConfiguration.getApiEndpoint()).thenReturn(appUrl);
- underTest = new GithubApplicationClientImpl(httpClient, appSecurity);
+ underTest = new GithubApplicationClientImpl(httpClient, appSecurity, gitHubSettings);
logTester.clear();
}
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[][] {
assertThat(organizations.getOrganizations()).extracting(GithubApplicationClient.Organization::getLogin).containsOnly("github", "octocat");
}
+ @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";
--- /dev/null
+/*
+ * 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);
+ }
+
+}
--- /dev/null
+/*
+ * 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.server.almintegration.ws.github.config;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.apache.commons.io.IOUtils;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.alm.client.github.config.ConfigCheckResult;
+import org.sonar.alm.client.github.config.ConfigCheckResult.ApplicationStatus;
+import org.sonar.alm.client.github.config.GithubProvisioningConfigValidator;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.TestRequest;
+import org.sonar.server.ws.TestResponse;
+import org.sonar.server.ws.WsActionTester;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+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;
+import static org.sonar.test.JsonAssert.assertJson;
+
+public class CheckActionIT {
+
+ @Rule
+ public UserSessionRule userSession = UserSessionRule.standalone();
+
+ private GithubProvisioningConfigValidator configValidator = mock(GithubProvisioningConfigValidator.class);
+
+ private final WsActionTester ws = new WsActionTester(new CheckAction(userSession, configValidator));
+
+ @Test
+ public void test_definition() {
+ WebService.Action def = ws.getDef();
+ assertThat(def.key()).isEqualTo("check");
+ assertThat(def.since()).isEqualTo("10.1");
+ assertThat(def.isInternal()).isTrue();
+ assertThat(def.isPost()).isTrue();
+ assertThat(def.params()).isEmpty();
+ assertThat(def.responseExample()).isNotNull();
+ assertThat(def.responseExample()).isEqualTo(getClass().getResource("check-example.json"));
+ }
+
+ @Test
+ public void check_whenUserIsAdmin_shouldReturnCheckResult() throws IOException {
+ userSession.logIn().setSystemAdministrator();
+ ConfigCheckResult result = new ConfigCheckResult(
+ new ApplicationStatus(
+ ConfigStatus.SUCCESS,
+ ConfigStatus.failed("App validation failed")),
+ List.of(
+ new InstallationStatus(
+ "org1",
+ ConfigStatus.SUCCESS,
+ ConfigStatus.SUCCESS
+ ),
+ new InstallationStatus(
+ "org2",
+ ConfigStatus.SUCCESS,
+ ConfigStatus.failed("Organization validation failed.")
+ )
+ ));
+
+ when(configValidator.checkConfig()).thenReturn(result);
+ TestResponse response = ws.newRequest().execute();
+
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertJson(response.getInput()).isSimilarTo(readResponse("check-example.json"));
+ }
+
+ @Test
+ public void check_whenNotAnAdmin_shouldThrow() {
+ userSession.logIn("not-an-admin");
+
+ TestRequest testRequest = ws.newRequest();
+ assertThatThrownBy(testRequest::execute)
+ .hasMessage("Insufficient privileges")
+ .isInstanceOf(ForbiddenException.class);
+ }
+
+ private String readResponse(String file) throws IOException {
+ return IOUtils.toString(getClass().getResource(file), StandardCharsets.UTF_8);
+ }
+}
import org.sonar.server.almintegration.ws.github.ImportGithubProjectAction;
import org.sonar.server.almintegration.ws.github.ListGithubOrganizationsAction;
import org.sonar.server.almintegration.ws.github.ListGithubRepositoriesAction;
+import org.sonar.server.almintegration.ws.github.config.CheckAction;
import org.sonar.server.almintegration.ws.gitlab.ImportGitLabProjectAction;
import org.sonar.server.almintegration.ws.gitlab.SearchGitlabReposAction;
protected void configureModule() {
add(
CheckPatAction.class,
+ CheckAction.class,
SetPatAction.class,
ImportBitbucketServerProjectAction.class,
ImportBitbucketCloudRepoAction.class,
--- /dev/null
+/*
+ * 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.server.almintegration.ws.github;
+
+import org.sonar.server.ws.WsAction;
+
+public interface GithubProvisioningAction extends WsAction {
+}
--- /dev/null
+/*
+ * 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.server.almintegration.ws.github;
+
+import java.util.Set;
+import org.sonar.api.server.ServerSide;
+import org.sonar.api.server.ws.WebService;
+
+@ServerSide
+public class GithubProvisioningWs implements WebService {
+
+ public static final String API_ENDPOINT = "api/github_provisioning";
+
+ private final Set<GithubProvisioningAction> actions;
+
+ public GithubProvisioningWs(Set<GithubProvisioningAction> actions) {
+ this.actions = actions;
+ }
+
+ @Override
+ public void define(Context context) {
+ NewController controller = context.createController(API_ENDPOINT)
+ .setDescription("Manage GitHub provisioning.")
+ .setSince("10.1");
+
+ actions.forEach(action -> action.define(controller));
+ controller.done();
+ }
+}
--- /dev/null
+/*
+ * 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.server.almintegration.ws.github.config;
+
+import com.google.gson.GsonBuilder;
+import java.nio.charset.StandardCharsets;
+import javax.servlet.http.HttpServletResponse;
+import org.sonar.alm.client.github.config.ConfigCheckResult;
+import org.sonar.alm.client.github.config.GithubProvisioningConfigValidator;
+import org.sonar.api.server.ServerSide;
+import org.sonar.api.server.ws.Request;
+import org.sonar.api.server.ws.Response;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.server.almintegration.ws.github.GithubProvisioningAction;
+import org.sonar.server.user.UserSession;
+import org.sonarqube.ws.MediaTypes;
+
+@ServerSide
+public class CheckAction implements GithubProvisioningAction {
+
+ private final UserSession userSession;
+ private final GithubProvisioningConfigValidator githubProvisioningConfigValidator;
+
+ public CheckAction(UserSession userSession, GithubProvisioningConfigValidator githubProvisioningConfigValidator) {
+ this.userSession = userSession;
+ this.githubProvisioningConfigValidator = githubProvisioningConfigValidator;
+ }
+
+ @Override
+ public void define(WebService.NewController controller) {
+ controller
+ .createAction("check")
+ .setPost(true)
+ .setDescription("Validate Github provisioning configuration.")
+ .setHandler(this)
+ .setInternal(true)
+ .setResponseExample(getClass().getResource("check-example.json"))
+ .setSince("10.1");
+ }
+
+ @Override
+ public void handle(Request request, Response response) throws Exception {
+ userSession.checkIsSystemAdministrator();
+
+ ConfigCheckResult result = githubProvisioningConfigValidator.checkConfig();
+
+ response.stream().setStatus(HttpServletResponse.SC_OK);
+ response.stream().setMediaType(MediaTypes.JSON);
+ response.stream().output().write(new GsonBuilder().create().toJson(result).getBytes(StandardCharsets.UTF_8));
+ response.stream().output().flush();
+ }
+}
--- /dev/null
+{
+ "application": {
+ "jit": {
+ "status": "SUCCESS"
+ },
+ "autoProvisioning": {
+ "status": "FAILED",
+ "errorMessage": "App validation failed"
+ }
+ },
+ "installations": [
+ {
+ "organization": "org1",
+ "jit": {
+ "status": "SUCCESS"
+ },
+ "autoProvisioning": {
+ "status": "SUCCESS"
+ }
+ },
+ {
+ "organization": "org2",
+ "jit": {
+ "status": "SUCCESS"
+ },
+ "autoProvisioning": {
+ "status": "FAILED",
+ "errorMessage": "Organization validation failed."
+ }
+ }
+ ]
+}
--- /dev/null
+/*
+ * 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.server.almintegration.ws.github;
+
+import java.util.Set;
+import org.junit.Test;
+import org.sonar.api.server.ws.WebService;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.only;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class GithubProvisioningWsTest {
+ @Test
+ public void define_createsOneController() {
+ WebService.Context context = mock(WebService.Context.class);
+ GithubProvisioningAction action = mock(GithubProvisioningAction.class);
+ WebService.NewController controller = mock(WebService.NewController.class);
+ GithubProvisioningWs scimWs = new GithubProvisioningWs(Set.of(action));
+
+ when(context.createController("api/github_provisioning")).thenReturn(controller);
+ when(controller.setDescription(any())).thenReturn(controller);
+ when(controller.setSince(any())).thenReturn(controller);
+
+ scimWs.define(context);
+
+ verify(context, only()).createController("api/github_provisioning");
+ verify(action, only()).define(any());
+ }
+}
import org.sonar.alm.client.github.GithubApplicationClientImpl;
import org.sonar.alm.client.github.GithubApplicationHttpClientImpl;
import org.sonar.alm.client.github.GithubGlobalSettingsValidator;
+import org.sonar.alm.client.github.config.GithubProvisioningConfigValidator;
import org.sonar.alm.client.github.security.GithubAppSecurityImpl;
import org.sonar.alm.client.gitlab.GitlabGlobalSettingsValidator;
import org.sonar.alm.client.gitlab.GitlabHttpClient;
import org.sonar.server.almintegration.ws.CredentialsEncoderHelper;
import org.sonar.server.almintegration.ws.ImportHelper;
import org.sonar.server.almintegration.ws.ProjectKeyGenerator;
+import org.sonar.server.almintegration.ws.github.GithubProvisioningWs;
import org.sonar.server.almsettings.MultipleAlmFeature;
import org.sonar.server.almsettings.ws.AlmSettingsWsModule;
import org.sonar.server.authentication.AuthenticationModule;
GithubAppSecurityImpl.class,
GithubApplicationClientImpl.class,
GithubApplicationHttpClientImpl.class,
+ GithubProvisioningConfigValidator.class,
+ GithubProvisioningWs.class,
BitbucketCloudRestClientConfiguration.class,
BitbucketServerRestClient.class,
GitlabHttpClient.class,