From 26ef0dd4c8bcf84210b05c575fbdd60feecd53f8 Mon Sep 17 00:00:00 2001 From: Kalle Olavi Niemitalo Date: Sun, 12 Jan 2025 14:59:44 +0200 Subject: [PATCH] [JENKINS-74970] Advise if Bitbucket Server rejects build status If this plugin posts a build status to Bitbucket Server or Data Center, but Bitbucket responds with HTTP status 401 (Unauthorized) or 403 (Forbidden), then log extra information to help users grant Jenkins the required permission on the repository: * The permission that is needed: REPO_READ. * The Bitbucket user name. Currently, this comes from the "X-AUSERNAME" response header field. * The repository to which the request was sent. * The project or user that owns the repository. Because BitbucketServerAPIClient does not have direct access to a TaskListener, it adds this information to the message of the BitbucketRequestException, and the caller then logs it from there. The behaviour on Bitbucket Cloud is unchanged. --- .../impl/client/AbstractBitbucketApi.java | 39 ++++++++++++-- .../client/BitbucketServerAPIClient.java | 53 ++++++++++++++++++- .../plugins/bitbucket/Messages.properties | 1 + 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java index 4787827af..68839e9b8 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java @@ -47,6 +47,7 @@ import org.apache.commons.lang.StringUtils; import org.apache.http.Header; import org.apache.http.HttpHost; +import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.NameValuePair; import org.apache.http.auth.AuthScope; @@ -97,9 +98,14 @@ protected String truncateMiddle(@CheckForNull String value, int maxLength) { } } - protected BitbucketRequestException buildResponseException(CloseableHttpResponse response, String errorMessage) { + protected BitbucketRequestException buildResponseException(CloseableHttpResponse response, + String errorMessage, + @CheckForNull String advice) { String headers = StringUtils.join(response.getAllHeaders(), "\n"); String message = String.format("HTTP request error.%nStatus: %s%nResponse: %s%n%s", response.getStatusLine(), errorMessage, headers); + if (advice != null) { + message = advice + System.lineSeparator() + message; + } return new BitbucketRequestException(response.getStatusLine().getStatusCode(), message); } @@ -234,6 +240,12 @@ protected CloseableHttpResponse executeMethod(HttpHost host, HttpRequestBase htt } protected String doRequest(HttpRequestBase request, boolean requireAuthentication) throws IOException { + return doRequest(request, requireAuthentication, HttpErrorAdvisor.NULL); + } + + protected String doRequest(HttpRequestBase request, + boolean requireAuthentication, + HttpErrorAdvisor advisor) throws IOException { try (CloseableHttpResponse response = executeMethod(getHost(), request, requireAuthentication)) { int statusCode = response.getStatusLine().getStatusCode(); if (statusCode == HttpStatus.SC_NOT_FOUND) { @@ -247,7 +259,7 @@ protected String doRequest(HttpRequestBase request, boolean requireAuthenticatio String content = getResponseContent(response); EntityUtils.consume(response.getEntity()); if (statusCode != HttpStatus.SC_OK && statusCode != HttpStatus.SC_CREATED) { - throw buildResponseException(response, content); + throw buildResponseException(response, content, advisor.getAdvice(response)); } return content; } catch (BitbucketRequestException e) { @@ -296,7 +308,7 @@ protected InputStream getRequestAsInputStream(String path) throws IOException { } if (statusCode != HttpStatus.SC_OK) { String content = getResponseContent(response); - throw buildResponseException(response, content); + throw buildResponseException(response, content, null); } return new ClosingConnectionInputStream(response, httpget, getConnectionManager()); } @@ -351,4 +363,25 @@ public void close() throws Exception { protected BitbucketAuthenticator getAuthenticator() { return authenticator; } + + /** + * REST API operation methods in classes derived from {@link AbstractBitbucketApi} + * can implement this interface to explain to users why + * {@link #doRequest(HttpRequestBase, boolean, HttpErrorAdvisor)} failed. + */ + @FunctionalInterface + protected interface HttpErrorAdvisor { + /** + * Gets user-readable advice on why Bitbucket returned an error HTTP status. + * + * @param response The HTTP response from Bitbucket. + * @return Advice to the user on why the request failed, or {@code null}. + */ + @CheckForNull String getAdvice(HttpResponse response); + + /** + * A trivial {@link HttpErrorAdvisor} implementation that never has advice. + */ + public static final HttpErrorAdvisor NULL = response -> null; + } } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClient.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClient.java index ccd7d59ca..74c6c6085 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClient.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClient.java @@ -23,6 +23,7 @@ */ package com.cloudbees.jenkins.plugins.bitbucket.server.client; +import com.cloudbees.jenkins.plugins.bitbucket.Messages; import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketApi; import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator; import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketBuildStatus; @@ -87,10 +88,15 @@ import jenkins.scm.api.SCMFile; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; +import org.apache.http.Header; import org.apache.http.HttpHost; +import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; import org.apache.http.conn.HttpClientConnectionManager; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; import org.apache.http.message.BasicNameValuePair; @@ -509,7 +515,11 @@ public void postBuildStatus(@NonNull BitbucketBuildStatus status) throws IOExcep .set("repo", repositoryName) .set("hash", newStatus.getHash()) .expand(); - postRequest(url, JsonParser.toJson(newStatus)); + + HttpPost request = new HttpPost(url); + request.setEntity(new StringEntity(JsonParser.toJson(newStatus), + ContentType.create("application/json", "UTF-8"))); + doRequest(request, true, this::adviceForBuildStatusError); } /** @@ -1051,4 +1061,45 @@ private Map collectLines(String response, final List line return content; } + // Gets user-visible advice for an HTTP error response when + // Bitbucket Server rejects a build status. + // Implements AbstractBitbucketApi.HttpErrorAdvisor#getAdvice. + @CheckForNull + private String adviceForBuildStatusError(HttpResponse response) { + // If the HTTP request failed because of an authorization + // problem, then make the exception message also show the + // Bitbucket user name with which Jenkins authenticated, + // the project name, and the repository name. + // + // Such an authorization problem can occur especially in a + // pull request from a personal fork: if Jenkins has been + // granted REPO_READ access on the target repository of the PR + // but no access on the fork, then it can read the PR + // information from the target repository and check out the + // files, but cannot post a build status to the fork. + // Showing the name of the fork will help the user or + // administrator grant the required access. + // + // If the HTTP request already includes valid credentials, + // but the Bitbucket user has not been granted access on the + // repository, then Bitbucket Server responds with HTTP status + // 401 (Unauthorized) and a WWW-Authenticate header field that + // requests OAuth, even though RFC 7235 section 2.1 recommends + // 403 (Forbidden). Let's recognize both 401 and 403. + int httpStatus = response.getStatusLine().getStatusCode(); + if (httpStatus == HttpStatus.SC_UNAUTHORIZED || httpStatus == HttpStatus.SC_FORBIDDEN) { + Header userNameHeader = response.getFirstHeader("X-AUSERNAME"); + if (userNameHeader != null + && !userNameHeader.getValue().equals("anonymous")) { + // Posting a build status requires REPO_READ access. + // https://docs.atlassian.com/bitbucket-server/rest/7.4.0/bitbucket-rest.html#idp219 + return Messages.BitbucketServerAPIClient_adviceForBuildStatusError( + userNameHeader.getValue(), + getUserCentricOwner(), + getRepositoryName()); + } + } + + return null; + } } diff --git a/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/Messages.properties b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/Messages.properties index fea0c6eed..883ad7a66 100644 --- a/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/Messages.properties +++ b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/Messages.properties @@ -65,3 +65,4 @@ BitbucketTagSCMHead.Pronoun=Tag TagDiscoveryTrait.authorityDisplayName=Trust origin tags BitbucketBuildStatusNotificationsTrait.displayName=Bitbucket build status notifications DiscardOldBranchTrait.displayName=Discard branch older than given days +BitbucketServerAPIClient.adviceForBuildStatusError=Please verify that the Bitbucket user "{0}" is granted REPO_READ access on the repository "{1}/{2}".