123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- /*
- * SonarQube
- * Copyright (C) 2009-2024 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;
-
- import com.google.gson.Gson;
- import com.google.gson.reflect.TypeToken;
- import java.io.IOException;
- import java.lang.reflect.Type;
- import java.net.URI;
- import java.util.Arrays;
- import java.util.HashMap;
- import java.util.List;
- import java.util.Locale;
- import java.util.Map;
- import java.util.Objects;
- import java.util.Optional;
- import java.util.Set;
- import java.util.function.Function;
- import java.util.stream.Collectors;
- import javax.annotation.Nullable;
- import org.slf4j.Logger;
- import org.slf4j.LoggerFactory;
- import org.sonar.alm.client.ApplicationHttpClient;
- import org.sonar.alm.client.ApplicationHttpClient.GetResponse;
- import org.sonar.auth.github.AppInstallationToken;
- import org.sonar.auth.github.GithubBinding;
- import org.sonar.auth.github.GithubBinding.GsonGithubRepository;
- import org.sonar.auth.github.GithubBinding.GsonInstallations;
- import org.sonar.auth.github.GithubBinding.GsonRepositorySearch;
- import org.sonar.auth.github.GsonRepositoryCollaborator;
- import org.sonar.auth.github.GsonRepositoryTeam;
- import org.sonar.auth.github.GithubAppConfiguration;
- import org.sonar.auth.github.GithubAppInstallation;
- import org.sonar.auth.github.security.AccessToken;
- import org.sonar.alm.client.github.security.AppToken;
- import org.sonar.alm.client.github.security.GithubAppSecurity;
- import org.sonar.auth.github.security.UserAccessToken;
- import org.sonar.alm.client.gitlab.GsonApp;
- import org.sonar.api.internal.apachecommons.lang.StringUtils;
- import org.sonar.auth.github.GitHubSettings;
- import org.sonar.auth.github.client.GithubApplicationClient;
- 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;
-
- public class GithubApplicationClientImpl implements GithubApplicationClient {
- private static final Logger LOG = LoggerFactory.getLogger(GithubApplicationClientImpl.class);
- protected static final Gson GSON = new Gson();
-
- protected static final String WRITE_PERMISSION_NAME = "write";
- protected static final String READ_PERMISSION_NAME = "read";
- protected static final String FAILED_TO_REQUEST_BEGIN_MSG = "Failed to request ";
- private static final TypeToken<List<GsonRepositoryTeam>> REPOSITORY_TEAM_LIST_TYPE = new TypeToken<>() {
- };
- private static final TypeToken<List<GsonRepositoryCollaborator>> REPOSITORY_COLLABORATORS_LIST_TYPE = new TypeToken<>() {
- };
- private static final TypeToken<List<GithubBinding.GsonInstallation>> ORGANIZATION_LIST_TYPE = new TypeToken<>() {
- };
- protected final GithubApplicationHttpClient githubApplicationHttpClient;
- protected final GithubAppSecurity appSecurity;
- private final GitHubSettings gitHubSettings;
- private final GithubPaginatedHttpClient githubPaginatedHttpClient;
-
- public GithubApplicationClientImpl(GithubApplicationHttpClient githubApplicationHttpClient, GithubAppSecurity appSecurity, GitHubSettings gitHubSettings,
- GithubPaginatedHttpClient githubPaginatedHttpClient) {
- this.githubApplicationHttpClient = githubApplicationHttpClient;
- this.appSecurity = appSecurity;
- this.gitHubSettings = gitHubSettings;
- this.githubPaginatedHttpClient = githubPaginatedHttpClient;
- }
-
- private static void checkPageArgs(int page, int pageSize) {
- checkArgument(page > 0, "'page' must be larger than 0.");
- checkArgument(pageSize > 0 && pageSize <= 100, "'pageSize' must be a value larger than 0 and smaller or equal to 100.");
- }
-
- @Override
- public Optional<AppInstallationToken> createAppInstallationToken(GithubAppConfiguration githubAppConfiguration, long installationId) {
- AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey());
- String endPoint = "/app/installations/" + installationId + "/access_tokens";
- return post(githubAppConfiguration.getApiEndpoint(), appToken, endPoint, GithubBinding.GsonInstallationToken.class)
- .map(GithubBinding.GsonInstallationToken::getToken)
- .filter(Objects::nonNull)
- .map(AppInstallationToken::new);
- }
-
- private <T> Optional<T> post(String baseUrl, AccessToken token, String endPoint, Class<T> gsonClass) {
- try {
- ApplicationHttpClient.Response response = githubApplicationHttpClient.post(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 void checkApiEndpoint(GithubAppConfiguration githubAppConfiguration) {
- if (StringUtils.isBlank(githubAppConfiguration.getApiEndpoint())) {
- throw new IllegalArgumentException("Missing URL");
- }
-
- URI apiEndpoint;
- try {
- apiEndpoint = URI.create(githubAppConfiguration.getApiEndpoint());
- } catch (IllegalArgumentException e) {
- throw new IllegalArgumentException("Invalid URL, " + e.getMessage());
- }
-
- if (!"http".equalsIgnoreCase(apiEndpoint.getScheme()) && !"https".equalsIgnoreCase(apiEndpoint.getScheme())) {
- throw new IllegalArgumentException("Only http and https schemes are supported");
- } else if (!"api.github.com".equalsIgnoreCase(apiEndpoint.getHost()) && !apiEndpoint.getPath().toLowerCase(Locale.ENGLISH).startsWith("/api/v3")) {
- throw new IllegalArgumentException("Invalid GitHub URL");
- }
- }
-
- @Override
- public void checkAppPermissions(GithubAppConfiguration githubAppConfiguration) {
- AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey());
-
- Map<String, String> permissions = new HashMap<>();
- permissions.put("checks", WRITE_PERMISSION_NAME);
- permissions.put("pull_requests", WRITE_PERMISSION_NAME);
- permissions.put("metadata", READ_PERMISSION_NAME);
-
- String endPoint = "/app";
- GetResponse response;
- try {
- response = githubApplicationHttpClient.get(githubAppConfiguration.getApiEndpoint(), appToken, endPoint);
- } catch (IOException e) {
- LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + githubAppConfiguration.getApiEndpoint() + endPoint, e);
- throw new IllegalArgumentException("Failed to validate configuration, check URL and Private Key");
- }
- if (response.getCode() == HTTP_OK) {
- Map<String, String> perms = handleResponse(response, endPoint, GsonApp.class)
- .map(GsonApp::getPermissions)
- .orElseThrow(() -> new IllegalArgumentException("Failed to get app permissions, unexpected response body"));
- List<String> missingPermissions = permissions.entrySet().stream()
- .filter(permission -> !Objects.equals(permission.getValue(), perms.get(permission.getKey())))
- .map(Map.Entry::getKey)
- .toList();
-
- if (!missingPermissions.isEmpty()) {
- String message = missingPermissions.stream()
- .map(perm -> perm + " is '" + perms.get(perm) + "', should be '" + permissions.get(perm) + "'")
- .collect(Collectors.joining(", "));
-
- throw new IllegalArgumentException("Missing permissions; permission granted on " + message);
- }
- } else if (response.getCode() == HTTP_UNAUTHORIZED || response.getCode() == HTTP_FORBIDDEN) {
- throw new IllegalArgumentException("Authentication failed, verify the Client Id, Client Secret and Private Key fields");
- } else {
- throw new IllegalArgumentException("Failed to check permissions with Github, check the configuration");
- }
- }
-
- @Override
- public Optional<Long> getInstallationId(GithubAppConfiguration githubAppConfiguration, String repositorySlug) {
- AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey());
- String endpoint = String.format("/repos/%s/installation", repositorySlug);
- return get(githubAppConfiguration.getApiEndpoint(), appToken, endpoint, GithubBinding.GsonInstallation.class)
- .map(GithubBinding.GsonInstallation::getId)
- .filter(installationId -> installationId != 0L);
- }
-
- @Override
- public Organizations listOrganizations(String appUrl, AccessToken accessToken, int page, int pageSize) {
- checkPageArgs(page, pageSize);
-
- try {
- Organizations organizations = new Organizations();
- GetResponse response = githubApplicationHttpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", page, pageSize));
- Optional<GsonInstallations> gsonInstallations = response.getContent().map(content -> GSON.fromJson(content, GsonInstallations.class));
-
- if (!gsonInstallations.isPresent()) {
- return organizations;
- }
-
- organizations.setTotal(gsonInstallations.get().getTotalCount());
- if (gsonInstallations.get().getInstallations() != null) {
- organizations.setOrganizations(gsonInstallations.get().getInstallations().stream()
- .map(gsonInstallation -> new Organization(gsonInstallation.getAccount().getId(), gsonInstallation.getAccount().getLogin(), null, null, null, null, null,
- gsonInstallation.getTargetType()))
- .toList());
- }
-
- return organizations;
- } catch (IOException e) {
- throw new IllegalStateException(format("Failed to list all organizations accessible by user access token on %s", appUrl), e);
- }
- }
-
- @Override
- public List<GithubAppInstallation> getWhitelistedGithubAppInstallations(GithubAppConfiguration githubAppConfiguration) {
- List<GithubBinding.GsonInstallation> gsonAppInstallations = fetchAppInstallationsFromGithub(githubAppConfiguration);
- Set<String> allowedOrganizations = gitHubSettings.getOrganizations();
- return convertToGithubAppInstallationAndFilterWhitelisted(gsonAppInstallations, allowedOrganizations);
- }
-
- private static List<GithubAppInstallation> convertToGithubAppInstallationAndFilterWhitelisted(List<GithubBinding.GsonInstallation> gsonAppInstallations,
- Set<String> allowedOrganizations) {
- return gsonAppInstallations.stream()
- .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 List<GithubBinding.GsonInstallation> fetchAppInstallationsFromGithub(GithubAppConfiguration githubAppConfiguration) {
- AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey());
- String endpoint = "/app/installations";
-
- return executePaginatedQuery(githubAppConfiguration.getApiEndpoint(), appToken, endpoint, resp -> GSON.fromJson(resp, ORGANIZATION_LIST_TYPE));
- }
-
- protected <T> Optional<T> get(String baseUrl, AccessToken token, String endPoint, Class<T> gsonClass) {
- try {
- GetResponse response = githubApplicationHttpClient.get(baseUrl, token, endPoint);
- return handleResponse(response, endPoint, gsonClass);
- } catch (Exception e) {
- LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + endPoint, e);
- return Optional.empty();
- }
- }
-
- @Override
- public Repositories listRepositories(String appUrl, AccessToken accessToken, String organization, @Nullable String query, int page, int pageSize) {
- checkPageArgs(page, pageSize);
- String searchQuery = "fork:true+org:" + organization;
- if (query != null) {
- searchQuery = query.replace(" ", "+") + "+" + searchQuery;
- }
- try {
- Repositories repositories = new Repositories();
- GetResponse response = githubApplicationHttpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", searchQuery, page, pageSize));
- Optional<GsonRepositorySearch> gsonRepositories = response.getContent().map(content -> GSON.fromJson(content, GsonRepositorySearch.class));
- if (!gsonRepositories.isPresent()) {
- return repositories;
- }
-
- repositories.setTotal(gsonRepositories.get().getTotalCount());
-
- if (gsonRepositories.get().getItems() != null) {
- repositories.setRepositories(gsonRepositories.get().getItems().stream()
- .map(GsonGithubRepository::toRepository)
- .toList());
- }
-
- return repositories;
- } catch (Exception e) {
- throw new IllegalStateException(format("Failed to list all repositories of '%s' accessible by user access token on '%s' using query '%s'", organization, appUrl, searchQuery),
- e);
- }
- }
-
- @Override
- public Optional<Repository> getRepository(String appUrl, AccessToken accessToken, String organizationAndRepository) {
- try {
- GetResponse response = githubApplicationHttpClient.get(appUrl, accessToken, String.format("/repos/%s", organizationAndRepository));
- return Optional.of(response)
- .filter(r -> r.getCode() == HTTP_OK)
- .flatMap(ApplicationHttpClient.Response::getContent)
- .map(content -> GSON.fromJson(content, GsonGithubRepository.class))
- .map(GsonGithubRepository::toRepository);
- } catch (Exception e) {
- throw new IllegalStateException(format("Failed to get repository '%s' on '%s' (this might be related to the GitHub App installation scope)",
- organizationAndRepository, appUrl), e);
- }
- }
-
- @Override
- public UserAccessToken createUserAccessToken(String appUrl, String clientId, String clientSecret, String code) {
- try {
- String endpoint = "/login/oauth/access_token?client_id=" + clientId + "&client_secret=" + clientSecret + "&code=" + code;
-
- String baseAppUrl;
- int apiIndex = appUrl.indexOf("/api/v3");
- if (apiIndex > 0) {
- baseAppUrl = appUrl.substring(0, apiIndex);
- } else if (appUrl.startsWith("https://api.github.com")) {
- baseAppUrl = "https://github.com";
- } else {
- baseAppUrl = appUrl;
- }
-
- ApplicationHttpClient.Response response = githubApplicationHttpClient.post(baseAppUrl, null, endpoint);
-
- if (response.getCode() != HTTP_OK) {
- throw new IllegalStateException("Failed to create GitHub's user access token. GitHub returned code " + code + ". " + response.getContent().orElse(""));
- }
-
- Optional<String> content = response.getContent();
- Optional<UserAccessToken> accessToken = content.flatMap(c -> Arrays.stream(c.split("&"))
- .filter(t -> t.startsWith("access_token="))
- .map(t -> t.split("=")[1])
- .findAny())
- .map(UserAccessToken::new);
-
- if (accessToken.isPresent()) {
- return accessToken.get();
- }
-
- // If token is not in the 200's body, it's because the client ID or client secret are incorrect
- LOG.error("Failed to create GitHub's user access token. GitHub's response: {}", content);
- throw new IllegalArgumentException();
- } catch (IOException e) {
- throw new IllegalStateException("Failed to create GitHub's user access token", e);
- }
- }
-
- @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 = githubApplicationHttpClient.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(ApplicationHttpClient.Response response, String endPoint, Class<T> gsonClass) {
- try {
- return response.getContent().map(c -> GSON.fromJson(c, gsonClass));
- } catch (Exception e) {
- LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + endPoint, e);
- return Optional.empty();
- }
- }
-
- @Override
- public Set<GsonRepositoryTeam> getRepositoryTeams(String appUrl, AccessToken accessToken, String orgName, String repoName) {
- return Set
- .copyOf(executePaginatedQuery(appUrl, accessToken, format("/repos/%s/%s/teams", orgName, repoName), resp -> GSON.fromJson(resp, REPOSITORY_TEAM_LIST_TYPE)));
- }
-
- @Override
- public Set<GsonRepositoryCollaborator> getRepositoryCollaborators(String appUrl, AccessToken accessToken, String orgName, String repoName) {
- return Set
- .copyOf(
- executePaginatedQuery(
- appUrl,
- accessToken,
- format("/repos/%s/%s/collaborators?affiliation=direct", orgName, repoName),
- resp -> GSON.fromJson(resp, REPOSITORY_COLLABORATORS_LIST_TYPE)));
- }
-
- private <E> List<E> executePaginatedQuery(String appUrl, AccessToken token, String query, Function<String, List<E>> responseDeserializer) {
- return githubPaginatedHttpClient.get(appUrl, token, query, responseDeserializer);
- }
-
- }
|