feat(dev-infra): Add oauth scope check to ensure necessary permissions for merge tooling (#37421)

Adds an assertion that the provided TOKEN has OAuth scope permissions for `repo`
as this is required for all merge attempts.

On failure, provides detailed error message with remediation steps for the user.

PR Close #37421
This commit is contained in:
Joey Perrott 2020-06-03 12:12:52 -07:00 committed by atscott
parent 57411c85b9
commit c025357fb8
4 changed files with 80 additions and 5 deletions

View File

@ -71,9 +71,9 @@ export class PullRequestFailure {
return new this(`Pull request could not be found upstream.`); return new this(`Pull request could not be found upstream.`);
} }
static insufficientPermissionsToMerge() { static insufficientPermissionsToMerge(
return new this( message = `Insufficient Github API permissions to merge pull request. Please ensure that ` +
`Insufficient Github API permissions to merge pull request. Please ` + `your auth token has write access.`) {
`ensure that your auth token has write access.`); return new this(message);
} }
} }

View File

@ -9,10 +9,15 @@
import * as Octokit from '@octokit/rest'; import * as Octokit from '@octokit/rest';
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process'; import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
import {info} from '../../utils/console'; import {info, yellow} from '../../utils/console';
import {MergeConfigWithRemote} from './config'; import {MergeConfigWithRemote} from './config';
/** Github response type extended to include the `x-oauth-scopes` headers presence. */
type RateLimitResponseWithOAuthScopeHeader = Octokit.Response<Octokit.RateLimitGetResponse>&{
headers: {'x-oauth-scopes': string};
};
/** Error for failed Github API requests. */ /** Error for failed Github API requests. */
export class GithubApiRequestError extends Error { export class GithubApiRequestError extends Error {
constructor(public status: number, message: string) { constructor(public status: number, message: string) {
@ -43,6 +48,8 @@ export class GitClient {
/** Instance of the authenticated Github octokit API. */ /** Instance of the authenticated Github octokit API. */
api: Octokit; api: Octokit;
/** The OAuth scopes available for the provided Github token. */
private _oauthScopes = Promise.resolve<string[]>([]);
/** Regular expression that matches the provided Github token. */ /** Regular expression that matches the provided Github token. */
private _tokenRegex = new RegExp(this._githubToken, 'g'); private _tokenRegex = new RegExp(this._githubToken, 'g');
@ -117,4 +124,55 @@ export class GitClient {
omitGithubTokenFromMessage(value: string): string { omitGithubTokenFromMessage(value: string): string {
return value.replace(this._tokenRegex, '<TOKEN>'); return value.replace(this._tokenRegex, '<TOKEN>');
} }
/**
* Assert the GitClient instance is using a token with permissions for the all of the
* provided OAuth scopes.
*/
async hasOauthScopes(...requestedScopes: string[]): Promise<true|{error: string}> {
const missingScopes: string[] = [];
const scopes = await this.getAuthScopes();
requestedScopes.forEach(scope => {
if (!scopes.includes(scope)) {
missingScopes.push(scope);
}
});
// If no missing scopes are found, return true to indicate all OAuth Scopes are available.
if (missingScopes.length === 0) {
return true;
}
/**
* Preconstructed error message to log to the user, providing missing scopes and
* remediation instructions.
**/
const error =
`The provided <TOKEN> does not have required permissions due to missing scope(s): ` +
`${yellow(missingScopes.join(', '))}\n\n` +
`Update the token in use at:\n` +
` https://github.com/settings/tokens\n\n` +
`Alternatively, a new token can be created at: https://github.com/settings/tokens/new\n`;
return {error};
}
/**
* Retrieves the OAuth scopes for the loaded Github token, returning the already retrived
* list of OAuth scopes if available.
**/
private getAuthScopes() {
// If the OAuth scopes have already been loaded, return the Promise containing them.
if (this._oauthScopes) {
return this._oauthScopes;
}
// OAuth scopes are loaded via the /rate_limit endpoint to prevent
// usage of a request against that rate_limit for this lookup.
this._oauthScopes = this.api.rateLimit.get().then(_response => {
const response = _response as RateLimitResponseWithOAuthScopeHeader;
const scopes: string = response.headers['x-oauth-scopes'] || '';
return scopes.split(',').map(scope => scope.trim());
});
return this._oauthScopes;
}
} }

View File

@ -110,6 +110,10 @@ export async function mergePullRequest(
red('An unknown Git error has been thrown. Please check the output ' + red('An unknown Git error has been thrown. Please check the output ' +
'above for details.')); 'above for details.'));
return false; return false;
case MergeStatus.GITHUB_ERROR:
error(red('An error related to interacting with Github has been discovered.'));
error(failure!.message);
return false;
case MergeStatus.FAILED: case MergeStatus.FAILED:
error(yellow(`Could not merge the specified pull request.`)); error(yellow(`Could not merge the specified pull request.`));
error(red(failure!.message)); error(red(failure!.message));

View File

@ -13,12 +13,16 @@ import {isPullRequest, loadAndValidatePullRequest,} from './pull-request';
import {GithubApiMergeStrategy} from './strategies/api-merge'; import {GithubApiMergeStrategy} from './strategies/api-merge';
import {AutosquashMergeStrategy} from './strategies/autosquash-merge'; import {AutosquashMergeStrategy} from './strategies/autosquash-merge';
/** Github OAuth scopes required for the merge task. */
const REQUIRED_SCOPES = ['repo'];
/** Describes the status of a pull request merge. */ /** Describes the status of a pull request merge. */
export const enum MergeStatus { export const enum MergeStatus {
UNKNOWN_GIT_ERROR, UNKNOWN_GIT_ERROR,
DIRTY_WORKING_DIR, DIRTY_WORKING_DIR,
SUCCESS, SUCCESS,
FAILED, FAILED,
GITHUB_ERROR,
} }
/** Result of a pull request merge. */ /** Result of a pull request merge. */
@ -48,6 +52,15 @@ export class PullRequestMergeTask {
* @param force Whether non-critical pull request failures should be ignored. * @param force Whether non-critical pull request failures should be ignored.
*/ */
async merge(prNumber: number, force = false): Promise<MergeResult> { async merge(prNumber: number, force = false): Promise<MergeResult> {
// Assert the authenticated GitClient has access on the required scopes.
const hasOauthScopes = await this.git.hasOauthScopes(...REQUIRED_SCOPES);
if (hasOauthScopes !== true) {
return {
status: MergeStatus.GITHUB_ERROR,
failure: PullRequestFailure.insufficientPermissionsToMerge(hasOauthScopes.error)
};
}
if (this.git.hasUncommittedChanges()) { if (this.git.hasUncommittedChanges()) {
return {status: MergeStatus.DIRTY_WORKING_DIR}; return {status: MergeStatus.DIRTY_WORKING_DIR};
} }