]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19346 GitHub config check available in Community Edition
authorAntoine Vigneau <antoine.vigneau@sonarsource.com>
Fri, 2 Jun 2023 12:14:30 +0000 (14:14 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 5 Jun 2023 20:02:48 +0000 (20:02 +0000)
16 files changed:
server/sonar-alm-client/build.gradle
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/ConfigCheckResult.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppInstallation.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidator.java [new file with mode: 0644]
server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java
server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidatorTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/it/java/org/sonar/server/almintegration/ws/github/config/CheckActionIT.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWs.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/config/CheckAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/resources/org/sonar/server/almintegration/ws/github/config/check-example.json [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWsTest.java [new file with mode: 0644]
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java

index a15661a6cbae12083d3ceef86c7deda077ec72b0..fa06b389318a6fb7d679f478273c133cc4667c16 100644 (file)
@@ -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')
 
index 7446d304ec659decdc9fce3a0803645e016f6ba0..11848ba0f4a81ca5086df20e36309696e0b3526e 100644 (file)
@@ -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,11 +44,19 @@ 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.
    */
index eb7a4718a34158d18dd710b0f0149d75c8def653..4b771efee8e8235dce4c3783d2ac4c695d556e85 100644 (file)
@@ -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) {
@@ -160,6 +168,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);
@@ -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 (file)
index 0000000..1af06e2
--- /dev/null
@@ -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 (file)
index 0000000..5f59f48
--- /dev/null
@@ -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 (file)
index 0000000..3e6bb8f
--- /dev/null
@@ -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;
+  }
+
+}
index fbe8aa95ca05774a918fbca61b91b16489499704..2c5e4a4726b3bdd1044f1aae1f108a9def8b4e74 100644 (file)
@@ -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[][] {
@@ -392,6 +453,82 @@ public class GithubApplicationClientImplTest {
     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";
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 (file)
index 0000000..e743102
--- /dev/null
@@ -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);
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/almintegration/ws/github/config/CheckActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/almintegration/ws/github/config/CheckActionIT.java
new file mode 100644 (file)
index 0000000..e4e8963
--- /dev/null
@@ -0,0 +1,107 @@
+/*
+ * 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);
+  }
+}
index 9bf9b138c31a888ea82c26ca8b84e34408bbcde8..73bec42c67e6221768eeae7697a97d8aaebb186d 100644 (file)
@@ -32,6 +32,7 @@ import org.sonar.server.almintegration.ws.github.GetGithubClientIdAction;
 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;
 
@@ -40,6 +41,7 @@ public class AlmIntegrationsWSModule extends Module {
   protected void configureModule() {
     add(
       CheckPatAction.class,
+      CheckAction.class,
       SetPatAction.class,
       ImportBitbucketServerProjectAction.class,
       ImportBitbucketCloudRepoAction.class,
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningAction.java
new file mode 100644 (file)
index 0000000..19e893b
--- /dev/null
@@ -0,0 +1,25 @@
+/*
+ * 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 {
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWs.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWs.java
new file mode 100644 (file)
index 0000000..9993d9b
--- /dev/null
@@ -0,0 +1,46 @@
+/*
+ * 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();
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/config/CheckAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/config/CheckAction.java
new file mode 100644 (file)
index 0000000..5cc7f13
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * 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();
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/almintegration/ws/github/config/check-example.json b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/almintegration/ws/github/config/check-example.json
new file mode 100644 (file)
index 0000000..eb77c4e
--- /dev/null
@@ -0,0 +1,32 @@
+{
+  "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."
+      }
+    }
+  ]
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWsTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWsTest.java
new file mode 100644 (file)
index 0000000..f6dce7a
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * 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());
+  }
+}
index d89f0e2280f78fc416562538c9c4b13defac1959..3ffdc677d6b2531a23b1942e5178019d9e2f6c94 100644 (file)
@@ -30,6 +30,7 @@ import org.sonar.alm.client.bitbucketserver.BitbucketServerSettingsValidator;
 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;
@@ -59,6 +60,7 @@ import org.sonar.server.almintegration.ws.AlmIntegrationsWSModule;
 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;
@@ -543,6 +545,8 @@ public class PlatformLevel4 extends PlatformLevel {
       GithubAppSecurityImpl.class,
       GithubApplicationClientImpl.class,
       GithubApplicationHttpClientImpl.class,
+      GithubProvisioningConfigValidator.class,
+      GithubProvisioningWs.class,
       BitbucketCloudRestClientConfiguration.class,
       BitbucketServerRestClient.class,
       GitlabHttpClient.class,