You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

GithubApplicationClientImpl.java 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2024 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. package org.sonar.alm.client.github;
  21. import com.google.gson.Gson;
  22. import com.google.gson.reflect.TypeToken;
  23. import java.io.IOException;
  24. import java.lang.reflect.Type;
  25. import java.net.URI;
  26. import java.util.Arrays;
  27. import java.util.HashMap;
  28. import java.util.List;
  29. import java.util.Locale;
  30. import java.util.Map;
  31. import java.util.Objects;
  32. import java.util.Optional;
  33. import java.util.Set;
  34. import java.util.function.Function;
  35. import java.util.stream.Collectors;
  36. import javax.annotation.Nullable;
  37. import org.slf4j.Logger;
  38. import org.slf4j.LoggerFactory;
  39. import org.sonar.alm.client.ApplicationHttpClient;
  40. import org.sonar.alm.client.ApplicationHttpClient.GetResponse;
  41. import org.sonar.auth.github.AppInstallationToken;
  42. import org.sonar.auth.github.GithubBinding;
  43. import org.sonar.auth.github.GithubBinding.GsonGithubRepository;
  44. import org.sonar.auth.github.GithubBinding.GsonInstallations;
  45. import org.sonar.auth.github.GithubBinding.GsonRepositorySearch;
  46. import org.sonar.auth.github.GsonRepositoryCollaborator;
  47. import org.sonar.auth.github.GsonRepositoryTeam;
  48. import org.sonar.auth.github.GithubAppConfiguration;
  49. import org.sonar.auth.github.GithubAppInstallation;
  50. import org.sonar.auth.github.security.AccessToken;
  51. import org.sonar.alm.client.github.security.AppToken;
  52. import org.sonar.alm.client.github.security.GithubAppSecurity;
  53. import org.sonar.auth.github.security.UserAccessToken;
  54. import org.sonar.alm.client.gitlab.GsonApp;
  55. import org.sonar.api.internal.apachecommons.lang.StringUtils;
  56. import org.sonar.auth.github.GitHubSettings;
  57. import org.sonar.auth.github.client.GithubApplicationClient;
  58. import org.sonar.server.exceptions.ServerException;
  59. import org.sonarqube.ws.client.HttpException;
  60. import static com.google.common.base.Preconditions.checkArgument;
  61. import static java.lang.String.format;
  62. import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
  63. import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
  64. import static java.net.HttpURLConnection.HTTP_OK;
  65. import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
  66. public class GithubApplicationClientImpl implements GithubApplicationClient {
  67. private static final Logger LOG = LoggerFactory.getLogger(GithubApplicationClientImpl.class);
  68. protected static final Gson GSON = new Gson();
  69. protected static final String WRITE_PERMISSION_NAME = "write";
  70. protected static final String READ_PERMISSION_NAME = "read";
  71. protected static final String FAILED_TO_REQUEST_BEGIN_MSG = "Failed to request ";
  72. private static final TypeToken<List<GsonRepositoryTeam>> REPOSITORY_TEAM_LIST_TYPE = new TypeToken<>() {
  73. };
  74. private static final TypeToken<List<GsonRepositoryCollaborator>> REPOSITORY_COLLABORATORS_LIST_TYPE = new TypeToken<>() {
  75. };
  76. private static final TypeToken<List<GithubBinding.GsonInstallation>> ORGANIZATION_LIST_TYPE = new TypeToken<>() {
  77. };
  78. protected final GithubApplicationHttpClient githubApplicationHttpClient;
  79. protected final GithubAppSecurity appSecurity;
  80. private final GitHubSettings gitHubSettings;
  81. private final GithubPaginatedHttpClient githubPaginatedHttpClient;
  82. public GithubApplicationClientImpl(GithubApplicationHttpClient githubApplicationHttpClient, GithubAppSecurity appSecurity, GitHubSettings gitHubSettings,
  83. GithubPaginatedHttpClient githubPaginatedHttpClient) {
  84. this.githubApplicationHttpClient = githubApplicationHttpClient;
  85. this.appSecurity = appSecurity;
  86. this.gitHubSettings = gitHubSettings;
  87. this.githubPaginatedHttpClient = githubPaginatedHttpClient;
  88. }
  89. private static void checkPageArgs(int page, int pageSize) {
  90. checkArgument(page > 0, "'page' must be larger than 0.");
  91. checkArgument(pageSize > 0 && pageSize <= 100, "'pageSize' must be a value larger than 0 and smaller or equal to 100.");
  92. }
  93. @Override
  94. public Optional<AppInstallationToken> createAppInstallationToken(GithubAppConfiguration githubAppConfiguration, long installationId) {
  95. AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey());
  96. String endPoint = "/app/installations/" + installationId + "/access_tokens";
  97. return post(githubAppConfiguration.getApiEndpoint(), appToken, endPoint, GithubBinding.GsonInstallationToken.class)
  98. .map(GithubBinding.GsonInstallationToken::getToken)
  99. .filter(Objects::nonNull)
  100. .map(AppInstallationToken::new);
  101. }
  102. private <T> Optional<T> post(String baseUrl, AccessToken token, String endPoint, Class<T> gsonClass) {
  103. try {
  104. ApplicationHttpClient.Response response = githubApplicationHttpClient.post(baseUrl, token, endPoint);
  105. return handleResponse(response, endPoint, gsonClass);
  106. } catch (Exception e) {
  107. LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + endPoint, e);
  108. return Optional.empty();
  109. }
  110. }
  111. @Override
  112. public void checkApiEndpoint(GithubAppConfiguration githubAppConfiguration) {
  113. if (StringUtils.isBlank(githubAppConfiguration.getApiEndpoint())) {
  114. throw new IllegalArgumentException("Missing URL");
  115. }
  116. URI apiEndpoint;
  117. try {
  118. apiEndpoint = URI.create(githubAppConfiguration.getApiEndpoint());
  119. } catch (IllegalArgumentException e) {
  120. throw new IllegalArgumentException("Invalid URL, " + e.getMessage());
  121. }
  122. if (!"http".equalsIgnoreCase(apiEndpoint.getScheme()) && !"https".equalsIgnoreCase(apiEndpoint.getScheme())) {
  123. throw new IllegalArgumentException("Only http and https schemes are supported");
  124. } else if (!"api.github.com".equalsIgnoreCase(apiEndpoint.getHost()) && !apiEndpoint.getPath().toLowerCase(Locale.ENGLISH).startsWith("/api/v3")) {
  125. throw new IllegalArgumentException("Invalid GitHub URL");
  126. }
  127. }
  128. @Override
  129. public void checkAppPermissions(GithubAppConfiguration githubAppConfiguration) {
  130. AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey());
  131. Map<String, String> permissions = new HashMap<>();
  132. permissions.put("checks", WRITE_PERMISSION_NAME);
  133. permissions.put("pull_requests", WRITE_PERMISSION_NAME);
  134. permissions.put("metadata", READ_PERMISSION_NAME);
  135. String endPoint = "/app";
  136. GetResponse response;
  137. try {
  138. response = githubApplicationHttpClient.get(githubAppConfiguration.getApiEndpoint(), appToken, endPoint);
  139. } catch (IOException e) {
  140. LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + githubAppConfiguration.getApiEndpoint() + endPoint, e);
  141. throw new IllegalArgumentException("Failed to validate configuration, check URL and Private Key");
  142. }
  143. if (response.getCode() == HTTP_OK) {
  144. Map<String, String> perms = handleResponse(response, endPoint, GsonApp.class)
  145. .map(GsonApp::getPermissions)
  146. .orElseThrow(() -> new IllegalArgumentException("Failed to get app permissions, unexpected response body"));
  147. List<String> missingPermissions = permissions.entrySet().stream()
  148. .filter(permission -> !Objects.equals(permission.getValue(), perms.get(permission.getKey())))
  149. .map(Map.Entry::getKey)
  150. .toList();
  151. if (!missingPermissions.isEmpty()) {
  152. String message = missingPermissions.stream()
  153. .map(perm -> perm + " is '" + perms.get(perm) + "', should be '" + permissions.get(perm) + "'")
  154. .collect(Collectors.joining(", "));
  155. throw new IllegalArgumentException("Missing permissions; permission granted on " + message);
  156. }
  157. } else if (response.getCode() == HTTP_UNAUTHORIZED || response.getCode() == HTTP_FORBIDDEN) {
  158. throw new IllegalArgumentException("Authentication failed, verify the Client Id, Client Secret and Private Key fields");
  159. } else {
  160. throw new IllegalArgumentException("Failed to check permissions with Github, check the configuration");
  161. }
  162. }
  163. @Override
  164. public Optional<Long> getInstallationId(GithubAppConfiguration githubAppConfiguration, String repositorySlug) {
  165. AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey());
  166. String endpoint = String.format("/repos/%s/installation", repositorySlug);
  167. return get(githubAppConfiguration.getApiEndpoint(), appToken, endpoint, GithubBinding.GsonInstallation.class)
  168. .map(GithubBinding.GsonInstallation::getId)
  169. .filter(installationId -> installationId != 0L);
  170. }
  171. @Override
  172. public Organizations listOrganizations(String appUrl, AccessToken accessToken, int page, int pageSize) {
  173. checkPageArgs(page, pageSize);
  174. try {
  175. Organizations organizations = new Organizations();
  176. GetResponse response = githubApplicationHttpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", page, pageSize));
  177. Optional<GsonInstallations> gsonInstallations = response.getContent().map(content -> GSON.fromJson(content, GsonInstallations.class));
  178. if (!gsonInstallations.isPresent()) {
  179. return organizations;
  180. }
  181. organizations.setTotal(gsonInstallations.get().getTotalCount());
  182. if (gsonInstallations.get().getInstallations() != null) {
  183. organizations.setOrganizations(gsonInstallations.get().getInstallations().stream()
  184. .map(gsonInstallation -> new Organization(gsonInstallation.getAccount().getId(), gsonInstallation.getAccount().getLogin(), null, null, null, null, null,
  185. gsonInstallation.getTargetType()))
  186. .toList());
  187. }
  188. return organizations;
  189. } catch (IOException e) {
  190. throw new IllegalStateException(format("Failed to list all organizations accessible by user access token on %s", appUrl), e);
  191. }
  192. }
  193. @Override
  194. public List<GithubAppInstallation> getWhitelistedGithubAppInstallations(GithubAppConfiguration githubAppConfiguration) {
  195. List<GithubBinding.GsonInstallation> gsonAppInstallations = fetchAppInstallationsFromGithub(githubAppConfiguration);
  196. Set<String> allowedOrganizations = gitHubSettings.getOrganizations();
  197. return convertToGithubAppInstallationAndFilterWhitelisted(gsonAppInstallations, allowedOrganizations);
  198. }
  199. private static List<GithubAppInstallation> convertToGithubAppInstallationAndFilterWhitelisted(List<GithubBinding.GsonInstallation> gsonAppInstallations,
  200. Set<String> allowedOrganizations) {
  201. return gsonAppInstallations.stream()
  202. .filter(appInstallation -> appInstallation.getAccount().getType().equalsIgnoreCase("Organization"))
  203. .map(GithubApplicationClientImpl::toGithubAppInstallation)
  204. .filter(appInstallation -> isOrganizationWhiteListed(allowedOrganizations, appInstallation.organizationName()))
  205. .toList();
  206. }
  207. private static GithubAppInstallation toGithubAppInstallation(GithubBinding.GsonInstallation gsonInstallation) {
  208. return new GithubAppInstallation(
  209. Long.toString(gsonInstallation.getId()),
  210. gsonInstallation.getAccount().getLogin(),
  211. gsonInstallation.getPermissions(),
  212. org.apache.commons.lang.StringUtils.isNotEmpty(gsonInstallation.getSuspendedAt()));
  213. }
  214. private static boolean isOrganizationWhiteListed(Set<String> allowedOrganizations, String organizationName) {
  215. return allowedOrganizations.isEmpty() || allowedOrganizations.contains(organizationName);
  216. }
  217. private List<GithubBinding.GsonInstallation> fetchAppInstallationsFromGithub(GithubAppConfiguration githubAppConfiguration) {
  218. AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey());
  219. String endpoint = "/app/installations";
  220. return executePaginatedQuery(githubAppConfiguration.getApiEndpoint(), appToken, endpoint, resp -> GSON.fromJson(resp, ORGANIZATION_LIST_TYPE));
  221. }
  222. protected <T> Optional<T> get(String baseUrl, AccessToken token, String endPoint, Class<T> gsonClass) {
  223. try {
  224. GetResponse response = githubApplicationHttpClient.get(baseUrl, token, endPoint);
  225. return handleResponse(response, endPoint, gsonClass);
  226. } catch (Exception e) {
  227. LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + endPoint, e);
  228. return Optional.empty();
  229. }
  230. }
  231. @Override
  232. public Repositories listRepositories(String appUrl, AccessToken accessToken, String organization, @Nullable String query, int page, int pageSize) {
  233. checkPageArgs(page, pageSize);
  234. String searchQuery = "fork:true+org:" + organization;
  235. if (query != null) {
  236. searchQuery = query.replace(" ", "+") + "+" + searchQuery;
  237. }
  238. try {
  239. Repositories repositories = new Repositories();
  240. GetResponse response = githubApplicationHttpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", searchQuery, page, pageSize));
  241. Optional<GsonRepositorySearch> gsonRepositories = response.getContent().map(content -> GSON.fromJson(content, GsonRepositorySearch.class));
  242. if (!gsonRepositories.isPresent()) {
  243. return repositories;
  244. }
  245. repositories.setTotal(gsonRepositories.get().getTotalCount());
  246. if (gsonRepositories.get().getItems() != null) {
  247. repositories.setRepositories(gsonRepositories.get().getItems().stream()
  248. .map(GsonGithubRepository::toRepository)
  249. .toList());
  250. }
  251. return repositories;
  252. } catch (Exception e) {
  253. throw new IllegalStateException(format("Failed to list all repositories of '%s' accessible by user access token on '%s' using query '%s'", organization, appUrl, searchQuery),
  254. e);
  255. }
  256. }
  257. @Override
  258. public Optional<Repository> getRepository(String appUrl, AccessToken accessToken, String organizationAndRepository) {
  259. try {
  260. GetResponse response = githubApplicationHttpClient.get(appUrl, accessToken, String.format("/repos/%s", organizationAndRepository));
  261. return Optional.of(response)
  262. .filter(r -> r.getCode() == HTTP_OK)
  263. .flatMap(ApplicationHttpClient.Response::getContent)
  264. .map(content -> GSON.fromJson(content, GsonGithubRepository.class))
  265. .map(GsonGithubRepository::toRepository);
  266. } catch (Exception e) {
  267. throw new IllegalStateException(format("Failed to get repository '%s' on '%s' (this might be related to the GitHub App installation scope)",
  268. organizationAndRepository, appUrl), e);
  269. }
  270. }
  271. @Override
  272. public UserAccessToken createUserAccessToken(String appUrl, String clientId, String clientSecret, String code) {
  273. try {
  274. String endpoint = "/login/oauth/access_token?client_id=" + clientId + "&client_secret=" + clientSecret + "&code=" + code;
  275. String baseAppUrl;
  276. int apiIndex = appUrl.indexOf("/api/v3");
  277. if (apiIndex > 0) {
  278. baseAppUrl = appUrl.substring(0, apiIndex);
  279. } else if (appUrl.startsWith("https://api.github.com")) {
  280. baseAppUrl = "https://github.com";
  281. } else {
  282. baseAppUrl = appUrl;
  283. }
  284. ApplicationHttpClient.Response response = githubApplicationHttpClient.post(baseAppUrl, null, endpoint);
  285. if (response.getCode() != HTTP_OK) {
  286. throw new IllegalStateException("Failed to create GitHub's user access token. GitHub returned code " + code + ". " + response.getContent().orElse(""));
  287. }
  288. Optional<String> content = response.getContent();
  289. Optional<UserAccessToken> accessToken = content.flatMap(c -> Arrays.stream(c.split("&"))
  290. .filter(t -> t.startsWith("access_token="))
  291. .map(t -> t.split("=")[1])
  292. .findAny())
  293. .map(UserAccessToken::new);
  294. if (accessToken.isPresent()) {
  295. return accessToken.get();
  296. }
  297. // If token is not in the 200's body, it's because the client ID or client secret are incorrect
  298. LOG.error("Failed to create GitHub's user access token. GitHub's response: {}", content);
  299. throw new IllegalArgumentException();
  300. } catch (IOException e) {
  301. throw new IllegalStateException("Failed to create GitHub's user access token", e);
  302. }
  303. }
  304. @Override
  305. public GithubBinding.GsonApp getApp(GithubAppConfiguration githubAppConfiguration) {
  306. AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey());
  307. String endpoint = "/app";
  308. return getOrThrowIfNotHttpOk(githubAppConfiguration.getApiEndpoint(), appToken, endpoint, GithubBinding.GsonApp.class);
  309. }
  310. private <T> T getOrThrowIfNotHttpOk(String baseUrl, AccessToken token, String endPoint, Class<T> gsonClass) {
  311. try {
  312. GetResponse response = githubApplicationHttpClient.get(baseUrl, token, endPoint);
  313. if (response.getCode() != HTTP_OK) {
  314. throw new HttpException(baseUrl + endPoint, response.getCode(), response.getContent().orElse(""));
  315. }
  316. return handleResponse(response, endPoint, gsonClass).orElseThrow(() -> new ServerException(HTTP_INTERNAL_ERROR, "Http response withuot content"));
  317. } catch (IOException e) {
  318. throw new ServerException(HTTP_INTERNAL_ERROR, e.getMessage());
  319. }
  320. }
  321. protected static <T> Optional<T> handleResponse(ApplicationHttpClient.Response response, String endPoint, Class<T> gsonClass) {
  322. try {
  323. return response.getContent().map(c -> GSON.fromJson(c, gsonClass));
  324. } catch (Exception e) {
  325. LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + endPoint, e);
  326. return Optional.empty();
  327. }
  328. }
  329. @Override
  330. public Set<GsonRepositoryTeam> getRepositoryTeams(String appUrl, AccessToken accessToken, String orgName, String repoName) {
  331. return Set
  332. .copyOf(executePaginatedQuery(appUrl, accessToken, format("/repos/%s/%s/teams", orgName, repoName), resp -> GSON.fromJson(resp, REPOSITORY_TEAM_LIST_TYPE)));
  333. }
  334. @Override
  335. public Set<GsonRepositoryCollaborator> getRepositoryCollaborators(String appUrl, AccessToken accessToken, String orgName, String repoName) {
  336. return Set
  337. .copyOf(
  338. executePaginatedQuery(
  339. appUrl,
  340. accessToken,
  341. format("/repos/%s/%s/collaborators?affiliation=direct", orgName, repoName),
  342. resp -> GSON.fromJson(resp, REPOSITORY_COLLABORATORS_LIST_TYPE)));
  343. }
  344. private <E> List<E> executePaginatedQuery(String appUrl, AccessToken token, String query, Function<String, List<E>> responseDeserializer) {
  345. return githubPaginatedHttpClient.get(appUrl, token, query, responseDeserializer);
  346. }
  347. }