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.

GitlabApplicationClient.java 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  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.gitlab;
  21. import com.google.gson.Gson;
  22. import com.google.gson.GsonBuilder;
  23. import com.google.gson.JsonParseException;
  24. import com.google.gson.JsonSyntaxException;
  25. import com.google.gson.reflect.TypeToken;
  26. import java.io.IOException;
  27. import java.io.UnsupportedEncodingException;
  28. import java.lang.reflect.Type;
  29. import java.net.URLEncoder;
  30. import java.util.Arrays;
  31. import java.util.List;
  32. import java.util.Optional;
  33. import java.util.Set;
  34. import java.util.function.Function;
  35. import javax.annotation.Nullable;
  36. import okhttp3.Headers;
  37. import okhttp3.OkHttpClient;
  38. import okhttp3.Request;
  39. import okhttp3.RequestBody;
  40. import okhttp3.Response;
  41. import org.apache.logging.log4j.util.Strings;
  42. import org.slf4j.Logger;
  43. import org.slf4j.LoggerFactory;
  44. import org.sonar.alm.client.TimeoutConfiguration;
  45. import org.sonar.api.server.ServerSide;
  46. import org.sonar.auth.gitlab.GsonGroup;
  47. import org.sonar.auth.gitlab.GsonUser;
  48. import org.sonarqube.ws.MediaTypes;
  49. import org.sonarqube.ws.client.OkHttpClientBuilder;
  50. import static java.lang.String.format;
  51. import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
  52. import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
  53. import static java.nio.charset.StandardCharsets.UTF_8;
  54. @ServerSide
  55. public class GitlabApplicationClient {
  56. private static final Logger LOG = LoggerFactory.getLogger(GitlabApplicationClient.class);
  57. private static final Gson GSON = new Gson();
  58. private static final TypeToken<List<GsonGroup>> GITLAB_GROUP = new TypeToken<>() {
  59. };
  60. private static final TypeToken<List<GsonUser>> GITLAB_USER = new TypeToken<>() {
  61. };
  62. protected static final String PRIVATE_TOKEN = "Private-Token";
  63. private static final String GITLAB_GROUPS_MEMBERS_ENDPOINT = "/groups/%s/members";
  64. protected final OkHttpClient client;
  65. private final GitlabPaginatedHttpClient gitlabPaginatedHttpClient;
  66. public GitlabApplicationClient(GitlabPaginatedHttpClient gitlabPaginatedHttpClient, TimeoutConfiguration timeoutConfiguration) {
  67. this.gitlabPaginatedHttpClient = gitlabPaginatedHttpClient;
  68. client = new OkHttpClientBuilder()
  69. .setConnectTimeoutMs(timeoutConfiguration.getConnectTimeout())
  70. .setReadTimeoutMs(timeoutConfiguration.getReadTimeout())
  71. .setFollowRedirects(false)
  72. .build();
  73. }
  74. public void checkReadPermission(@Nullable String gitlabUrl, @Nullable String personalAccessToken) {
  75. checkProjectAccess(gitlabUrl, personalAccessToken, "Could not validate GitLab read permission. Got an unexpected answer.");
  76. }
  77. public void checkUrl(@Nullable String gitlabUrl) {
  78. checkProjectAccess(gitlabUrl, null, "Could not validate GitLab url. Got an unexpected answer.");
  79. }
  80. private void checkProjectAccess(@Nullable String gitlabUrl, @Nullable String personalAccessToken, String errorMessage) {
  81. String url = format("%s/projects", gitlabUrl);
  82. LOG.debug("get projects : [{}]", url);
  83. Request.Builder builder = new Request.Builder()
  84. .url(url)
  85. .get();
  86. if (personalAccessToken != null) {
  87. builder.addHeader(PRIVATE_TOKEN, personalAccessToken);
  88. }
  89. Request request = builder.build();
  90. try (Response response = client.newCall(request).execute()) {
  91. checkResponseIsSuccessful(response, errorMessage);
  92. Project.parseJsonArray(response.body().string());
  93. } catch (JsonSyntaxException e) {
  94. throw new IllegalArgumentException("Could not parse GitLab answer to verify read permission. Got a non-json payload as result.");
  95. } catch (IOException e) {
  96. logException(url, e);
  97. throw new IllegalArgumentException(errorMessage);
  98. }
  99. }
  100. private static void logException(String url, IOException e) {
  101. String errorMessage = format("Gitlab API call to [%s] failed with error message : [%s]", url, e.getMessage());
  102. LOG.info(errorMessage, e);
  103. }
  104. public void checkToken(String gitlabUrl, String personalAccessToken) {
  105. String url = format("%s/user", gitlabUrl);
  106. LOG.debug("get current user : [{}]", url);
  107. Request.Builder builder = new Request.Builder()
  108. .addHeader(PRIVATE_TOKEN, personalAccessToken)
  109. .url(url)
  110. .get();
  111. Request request = builder.build();
  112. String errorMessage = "Could not validate GitLab token. Got an unexpected answer.";
  113. try (Response response = client.newCall(request).execute()) {
  114. checkResponseIsSuccessful(response, errorMessage);
  115. GsonId.parseOne(response.body().string());
  116. } catch (JsonSyntaxException e) {
  117. throw new IllegalArgumentException("Could not parse GitLab answer to verify token. Got a non-json payload as result.");
  118. } catch (IOException e) {
  119. logException(url, e);
  120. throw new IllegalArgumentException(errorMessage);
  121. }
  122. }
  123. public void checkWritePermission(String gitlabUrl, String personalAccessToken) {
  124. String url = format("%s/markdown", gitlabUrl);
  125. LOG.debug("verify write permission by formating some markdown : [{}]", url);
  126. Request.Builder builder = new Request.Builder()
  127. .url(url)
  128. .addHeader(PRIVATE_TOKEN, personalAccessToken)
  129. .addHeader("Content-Type", MediaTypes.JSON)
  130. .post(RequestBody.create("{\"text\":\"validating write permission\"}".getBytes(UTF_8)));
  131. Request request = builder.build();
  132. String errorMessage = "Could not validate GitLab write permission. Got an unexpected answer.";
  133. try (Response response = client.newCall(request).execute()) {
  134. checkResponseIsSuccessful(response, errorMessage);
  135. GsonMarkdown.parseOne(response.body().string());
  136. } catch (JsonSyntaxException e) {
  137. throw new IllegalArgumentException("Could not parse GitLab answer to verify write permission. Got a non-json payload as result.");
  138. } catch (IOException e) {
  139. logException(url, e);
  140. throw new IllegalArgumentException(errorMessage);
  141. }
  142. }
  143. private static String urlEncode(String value) {
  144. try {
  145. return URLEncoder.encode(value, UTF_8.toString());
  146. } catch (UnsupportedEncodingException ex) {
  147. throw new IllegalStateException(ex.getCause());
  148. }
  149. }
  150. protected static void checkResponseIsSuccessful(Response response) throws IOException {
  151. checkResponseIsSuccessful(response, "GitLab Merge Request did not happen, please check your configuration");
  152. }
  153. protected static void checkResponseIsSuccessful(Response response, String errorMessage) throws IOException {
  154. if (!response.isSuccessful()) {
  155. String body = response.body().string();
  156. LOG.error("Gitlab API call to [{}] failed with {} http code. gitlab response content : [{}]", response.request().url(), response.code(), body);
  157. if (isTokenRevoked(response, body)) {
  158. throw new GitlabServerException(response.code(), "Your GitLab token was revoked");
  159. } else if (isTokenExpired(response, body)) {
  160. throw new GitlabServerException(response.code(), "Your GitLab token is expired");
  161. } else if (isInsufficientScope(response, body)) {
  162. throw new GitlabServerException(response.code(), "Your GitLab token has insufficient scope");
  163. } else if (response.code() == HTTP_UNAUTHORIZED) {
  164. throw new GitlabServerException(response.code(), "Invalid personal access token");
  165. } else if (response.isRedirect()) {
  166. throw new GitlabServerException(response.code(), "Request was redirected, please provide the correct URL");
  167. } else {
  168. throw new GitlabServerException(response.code(), errorMessage);
  169. }
  170. }
  171. }
  172. private static boolean isTokenRevoked(Response response, String body) {
  173. if (response.code() == HTTP_UNAUTHORIZED) {
  174. try {
  175. Optional<GsonError> gitlabError = GsonError.parseOne(body);
  176. return gitlabError.map(GsonError::getErrorDescription).map(description -> description.contains("Token was revoked")).orElse(false);
  177. } catch (JsonParseException e) {
  178. // nothing to do
  179. }
  180. }
  181. return false;
  182. }
  183. private static boolean isTokenExpired(Response response, String body) {
  184. if (response.code() == HTTP_UNAUTHORIZED) {
  185. try {
  186. Optional<GsonError> gitlabError = GsonError.parseOne(body);
  187. return gitlabError.map(GsonError::getErrorDescription).map(description -> description.contains("Token is expired")).orElse(false);
  188. } catch (JsonParseException e) {
  189. // nothing to do
  190. }
  191. }
  192. return false;
  193. }
  194. private static boolean isInsufficientScope(Response response, String body) {
  195. if (response.code() == HTTP_FORBIDDEN) {
  196. try {
  197. Optional<GsonError> gitlabError = GsonError.parseOne(body);
  198. return gitlabError.map(GsonError::getError).map("insufficient_scope"::equals).orElse(false);
  199. } catch (JsonParseException e) {
  200. // nothing to do
  201. }
  202. }
  203. return false;
  204. }
  205. public Project getProject(String gitlabUrl, String pat, Long gitlabProjectId) {
  206. String url = format("%s/projects/%s", gitlabUrl, gitlabProjectId);
  207. LOG.debug("get project : [{}]", url);
  208. Request request = new Request.Builder()
  209. .addHeader(PRIVATE_TOKEN, pat)
  210. .get()
  211. .url(url)
  212. .build();
  213. try (Response response = client.newCall(request).execute()) {
  214. checkResponseIsSuccessful(response);
  215. String body = response.body().string();
  216. LOG.trace("loading project payload result : [{}]", body);
  217. return new GsonBuilder().create().fromJson(body, Project.class);
  218. } catch (JsonSyntaxException e) {
  219. throw new IllegalArgumentException("Could not parse GitLab answer to retrieve a project. Got a non-json payload as result.");
  220. } catch (IOException e) {
  221. logException(url, e);
  222. throw new IllegalStateException(e.getMessage(), e);
  223. }
  224. }
  225. //
  226. // This method is used to check if a user has REPORTER level access to the project, which is a requirement for PR decoration.
  227. // As of June 9, 2021 there is no better way to do this check and still support GitLab 11.7.
  228. //
  229. public Optional<Project> getReporterLevelAccessProject(String gitlabUrl, String pat, Long gitlabProjectId) {
  230. String url = format("%s/projects?min_access_level=20&id_after=%s&id_before=%s", gitlabUrl, gitlabProjectId - 1,
  231. gitlabProjectId + 1);
  232. LOG.debug("get project : [{}]", url);
  233. Request request = new Request.Builder()
  234. .addHeader(PRIVATE_TOKEN, pat)
  235. .get()
  236. .url(url)
  237. .build();
  238. try (Response response = client.newCall(request).execute()) {
  239. checkResponseIsSuccessful(response);
  240. String body = response.body().string();
  241. LOG.trace("loading project payload result : [{}]", body);
  242. List<Project> projects = Project.parseJsonArray(body);
  243. if (projects.isEmpty()) {
  244. return Optional.empty();
  245. } else {
  246. return Optional.of(projects.get(0));
  247. }
  248. } catch (JsonSyntaxException e) {
  249. throw new IllegalArgumentException("Could not parse GitLab answer to retrieve a project. Got a non-json payload as result.");
  250. } catch (IOException e) {
  251. logException(url, e);
  252. throw new IllegalStateException(e.getMessage(), e);
  253. }
  254. }
  255. public List<GitLabBranch> getBranches(String gitlabUrl, String pat, Long gitlabProjectId) {
  256. String url = format("%s/projects/%s/repository/branches", gitlabUrl, gitlabProjectId);
  257. LOG.debug("get branches : [{}]", url);
  258. Request request = new Request.Builder()
  259. .addHeader(PRIVATE_TOKEN, pat)
  260. .get()
  261. .url(url)
  262. .build();
  263. try (Response response = client.newCall(request).execute()) {
  264. checkResponseIsSuccessful(response);
  265. String body = response.body().string();
  266. LOG.trace("loading branches payload result : [{}]", body);
  267. return Arrays.asList(new GsonBuilder().create().fromJson(body, GitLabBranch[].class));
  268. } catch (JsonSyntaxException e) {
  269. throw new IllegalArgumentException("Could not parse GitLab answer to retrieve project branches. Got a non-json payload as result.");
  270. } catch (IOException e) {
  271. logException(url, e);
  272. throw new IllegalStateException(e.getMessage(), e);
  273. }
  274. }
  275. public ProjectList searchProjects(String gitlabUrl, String personalAccessToken, @Nullable String projectName,
  276. @Nullable Integer pageNumber, @Nullable Integer pageSize) {
  277. String url = format("%s/projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=%s%s%s",
  278. gitlabUrl,
  279. projectName == null ? "" : urlEncode(projectName),
  280. pageNumber == null ? "" : format("&page=%d", pageNumber),
  281. pageSize == null ? "" : format("&per_page=%d", pageSize)
  282. );
  283. LOG.debug("get projects : [{}]", url);
  284. Request request = new Request.Builder()
  285. .addHeader(PRIVATE_TOKEN, personalAccessToken)
  286. .url(url)
  287. .get()
  288. .build();
  289. try (Response response = client.newCall(request).execute()) {
  290. Headers headers = response.headers();
  291. checkResponseIsSuccessful(response, "Could not get projects from GitLab instance");
  292. List<Project> projectList = Project.parseJsonArray(response.body().string());
  293. int returnedPageNumber = parseAndGetIntegerHeader(headers.get("X-Page"));
  294. int returnedPageSize = parseAndGetIntegerHeader(headers.get("X-Per-Page"));
  295. String xtotal = headers.get("X-Total");
  296. Integer totalProjects = Strings.isEmpty(xtotal) ? null : parseAndGetIntegerHeader(xtotal);
  297. return new ProjectList(projectList, returnedPageNumber, returnedPageSize, totalProjects);
  298. } catch (JsonSyntaxException e) {
  299. throw new IllegalArgumentException("Could not parse GitLab answer to search projects. Got a non-json payload as result.");
  300. } catch (IOException e) {
  301. logException(url, e);
  302. throw new IllegalStateException(e.getMessage(), e);
  303. }
  304. }
  305. private static int parseAndGetIntegerHeader(@Nullable String header) {
  306. if (header == null) {
  307. throw new IllegalArgumentException("Pagination data from GitLab response is missing");
  308. } else {
  309. try {
  310. return Integer.parseInt(header);
  311. } catch (NumberFormatException e) {
  312. throw new IllegalArgumentException("Could not parse pagination number", e);
  313. }
  314. }
  315. }
  316. public Set<GsonGroup> getGroups(String gitlabUrl, String token) {
  317. return Set.copyOf(executePaginatedQuery(gitlabUrl, token, "/groups", resp -> GSON.fromJson(resp, GITLAB_GROUP)));
  318. }
  319. public Set<GsonUser> getDirectGroupMembers(String gitlabUrl, String token, String groupId) {
  320. return getMembers(gitlabUrl, token, format(GITLAB_GROUPS_MEMBERS_ENDPOINT, groupId));
  321. }
  322. public Set<GsonUser> getAllGroupMembers(String gitlabUrl, String token, String groupId) {
  323. return getMembers(gitlabUrl, token, format(GITLAB_GROUPS_MEMBERS_ENDPOINT + "/all", groupId));
  324. }
  325. private Set<GsonUser> getMembers(String gitlabUrl, String token, String endpoint) {
  326. return Set.copyOf(executePaginatedQuery(gitlabUrl, token, endpoint, resp -> GSON.fromJson(resp, GITLAB_USER)));
  327. }
  328. private <E> List<E> executePaginatedQuery(String appUrl, String token, String query, Function<String, List<E>> responseDeserializer) {
  329. GitlabToken gitlabToken = new GitlabToken(token);
  330. return gitlabPaginatedHttpClient.get(appUrl, gitlabToken, query, responseDeserializer);
  331. }
  332. }