From 3e6cc3cfe959accee271ca6f19a7f2fbd856dbbf Mon Sep 17 00:00:00 2001 From: Tim Soslow Date: Wed, 10 May 2023 16:56:33 -0500 Subject: [PATCH] Re-add permission check and crumb issuer support Signed-off-by: Tim Soslow --- plugins/jenkins-backend/api-report.md | 4 + .../src/service/jenkinsApi.test.ts | 109 +++++++++++++++++- .../jenkins-backend/src/service/jenkinsApi.ts | 57 ++++++++- plugins/jenkins-backend/src/service/router.ts | 28 ++++- 4 files changed, 192 insertions(+), 6 deletions(-) diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index 4893592483..fd1365547d 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -8,6 +8,8 @@ import { CompoundEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; @@ -83,5 +85,7 @@ export interface RouterOptions { jenkinsInfoProvider: JenkinsInfoProvider; // (undocumented) logger: Logger; + // (undocumented) + permissions?: PermissionEvaluator | PermissionAuthorizer; } ``` diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index 7f8703e4de..15f4bc651f 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -18,7 +18,9 @@ import { JenkinsApiImpl } from './jenkinsApi'; import jenkins from 'jenkins'; import { JenkinsInfo } from './jenkinsInfoProvider'; import { JenkinsBuild, JenkinsProject } from '../types'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import fetch, { Response } from 'node-fetch'; +import { ResponseError } from '@backstage/errors'; jest.mock('jenkins'); jest.mock('node-fetch'); @@ -34,6 +36,7 @@ const mockedJenkinsClient = { const mockedJenkins = jenkins as jest.Mocked; mockedJenkins.mockReturnValue(mockedJenkinsClient); +const resourceRef = 'component:default/example-component'; const jobFullName = 'example-jobName/foo'; const buildNumber = 19; const jenkinsInfo: JenkinsInfo = { @@ -42,10 +45,34 @@ const jenkinsInfo: JenkinsInfo = { jobFullName: 'example-jobName', }; +const fakePermissionApi = { + authorize: jest.fn().mockResolvedValue([ + { + result: AuthorizeResult.ALLOW, + }, + ]), + authorizeConditional: jest.fn(), +}; + +class NoErrorThrownError extends Error {} + +const getError = async (call: () => unknown): Promise => { + try { + await call(); + throw new NoErrorThrownError(); + } catch (error: unknown) { + return error as TError; + } +}; + describe('JenkinsApi', () => { - const jenkinsApi = new JenkinsApiImpl(); + const jenkinsApi = new JenkinsApiImpl(fakePermissionApi); const mockFetch = fetch as jest.MockedFunction; + afterEach(() => { + jest.clearAllMocks(); + }); + describe('getProjects', () => { const project: JenkinsProject = { actions: [], @@ -695,24 +722,100 @@ describe('JenkinsApi', () => { 'https://jenkins.example.com/job/example-jobName/job/foo/job/bar/19', ); }); + describe('getHeaders', () => { + const crumb = { crumb: 'foobar', crumbRequestField: '.crumb' }; + const json = jest.fn() as jest.MockedFunction; + json.mockResolvedValue(crumb); + const jenkinsInfoCrumb: JenkinsInfo = { ...jenkinsInfo, crumbIssuer: true }; + const jenkinsApiProto = Object.getPrototypeOf(jenkinsApi); + + it('adds crumb', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json } as Response); + const response = await jenkinsApiProto.getHeaders(jenkinsInfoCrumb); + expect(response).toEqual({ ...jenkinsInfo.headers, '.crumb': 'foobar' }); + }); + it('does not add crumb', async () => { + const response = await jenkinsApiProto.getHeaders(jenkinsInfo); + expect(response).toEqual(jenkinsInfo.headers); + }); + it('fails to get crumb', async () => { + mockFetch.mockResolvedValueOnce({ ok: false } as Response); + const error = await getError(async () => + jenkinsApiProto.getHeaders(jenkinsInfoCrumb), + ); + expect(error).toBeInstanceOf(ResponseError); + }); + }); describe('rebuildProject', () => { it('successfully rebuilds', async () => { - mockFetch.mockResolvedValue({ status: 200 } as Response); + mockFetch.mockResolvedValueOnce({ status: 200 } as Response); const status = await jenkinsApi.rebuildProject( jenkinsInfo, jobFullName, buildNumber, + resourceRef, ); expect(status).toEqual(200); }); it('fails to rebuild', async () => { - mockFetch.mockResolvedValue({ status: 401 } as Response); + mockFetch.mockResolvedValueOnce({ status: 401 } as Response); const status = await jenkinsApi.rebuildProject( jenkinsInfo, jobFullName, buildNumber, + resourceRef, ); expect(status).toEqual(401); }); + + it('should fail if it does not have required permissions', async () => { + fakePermissionApi.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.DENY, + }, + ]); + + mockFetch.mockResolvedValueOnce({ status: 200 } as Response); + const status = await jenkinsApi.rebuildProject( + jenkinsInfo, + jobFullName, + buildNumber, + resourceRef, + ); + expect(status).toEqual(401); + }); + + it('with crumbIssuer option', async () => { + const info: JenkinsInfo = { ...jenkinsInfo, crumbIssuer: true }; + mockFetch.mockResolvedValueOnce({ status: 200 } as Response); + const crumbHeaders = { headerName: 'headerValue', '.crumb': 'bar' }; + + const privateGetHeaders = jest.spyOn( + JenkinsApiImpl.prototype as any, + 'getHeaders', + ); + privateGetHeaders.mockImplementation(() => { + return crumbHeaders; + }); + + const status = await jenkinsApi.rebuildProject( + info, + jobFullName, + buildNumber, + resourceRef, + ); + expect(status).toEqual(200); + + type HeaderResponse = { + headerName: string; + '.crumb': string; + }; + type OptionResponse = { + headers: HeaderResponse; + method: string; + }; + const requestOptions: OptionResponse = mockFetch.mock.calls[0][1] as any; + expect(requestOptions.headers).toStrictEqual(crumbHeaders); + }); }); }); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 0d5d282bf7..135d695e18 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -23,6 +23,12 @@ import type { JenkinsProject, ScmDetails, } from '../types'; +import { + AuthorizeResult, + PermissionEvaluator, +} from '@backstage/plugin-permission-common'; +import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common'; +import { ResponseError } from '@backstage/errors'; import fetch, { HeaderInit } from 'node-fetch'; export class JenkinsApiImpl { @@ -59,6 +65,8 @@ export class JenkinsApiImpl { ${JenkinsApiImpl.jobTreeSpec} ]{0,50}`; + constructor(private readonly permissionApi?: PermissionEvaluator) {} + /** * Get a list of projects for the given JenkinsInfo. * @see ../../../jenkins/src/api/JenkinsApi.ts#getProjects @@ -141,14 +149,29 @@ export class JenkinsApiImpl { jenkinsInfo: JenkinsInfo, jobFullName: string, buildNumber: number, + resourceRef: string, + options?: { token?: string }, ): Promise { + if (this.permissionApi) { + const response = await this.permissionApi.authorize( + [{ permission: jenkinsExecutePermission, resourceRef }], + { token: options?.token }, + ); + // permission api returns always at least one item, we need to check only one result since we do not expect any additional results + const { result } = response[0]; + if (result === AuthorizeResult.DENY) { + return 401; + } + } + const buildUrl = this.getBuildUrl(jenkinsInfo, jobFullName, buildNumber); + const headers = await this.getHeaders(jenkinsInfo); // the current SDK only supports triggering a new build // replay the job by triggering request directly from Jenkins api const response = await fetch(`${buildUrl}/replay/rebuild`, { method: 'post', - headers: jenkinsInfo.headers as HeaderInit, + headers: headers, }); return response.status; } @@ -308,4 +331,36 @@ export class JenkinsApiImpl { const jobs = jobFullName.split('/'); return `${jenkinsInfo.baseUrl}/job/${jobs.join('/job/')}/${buildId}`; } + + private async getHeaders(jenkinsInfo: JenkinsInfo): Promise { + let headers = jenkinsInfo.headers as HeaderInit; + if (!jenkinsInfo.crumbIssuer) { + return headers; + } + const response = await fetch( + `${jenkinsInfo.baseUrl}/crumbIssuer/api/json`, + { + method: 'get', + headers: headers, + }, + ); + if (!response.ok) { + throw ResponseError.fromResponse(response); + } + type CrumbResponse = { + crumb: string; + crumbRequestField: string; + }; + + const crumbJson: CrumbResponse = await response.json(); + if ('crumb' in crumbJson && 'crumbRequestField' in crumbJson) { + const headerObject = { + ...jenkinsInfo.headers, + [crumbJson.crumbRequestField]: crumbJson.crumb, + }; + headers = headerObject as HeaderInit; + } + + return headers; + } } diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 5f82f5e933..484bfa273f 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -20,22 +20,41 @@ import Router from 'express-promise-router'; import { Logger } from 'winston'; import { JenkinsInfoProvider } from './jenkinsInfoProvider'; import { JenkinsApiImpl } from './jenkinsApi'; +import { + PermissionAuthorizer, + PermissionEvaluator, + toPermissionEvaluator, +} from '@backstage/plugin-permission-common'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; +import { stringifyEntityRef } from '@backstage/catalog-model'; import { stringifyError } from '@backstage/errors'; /** @public */ export interface RouterOptions { logger: Logger; jenkinsInfoProvider: JenkinsInfoProvider; + permissions?: PermissionEvaluator | PermissionAuthorizer; } /** @public */ export async function createRouter( options: RouterOptions, ): Promise { - const { jenkinsInfoProvider } = options; + const { jenkinsInfoProvider, permissions, logger } = options; - const jenkinsApi = new JenkinsApiImpl(); + let permissionEvaluator: PermissionEvaluator | undefined; + if (permissions && 'authorizeConditional' in permissions) { + permissionEvaluator = permissions as PermissionEvaluator; + } else { + logger.warn( + 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', + ); + permissionEvaluator = permissions + ? toPermissionEvaluator(permissions) + : undefined; + } + + const jenkinsApi = new JenkinsApiImpl(permissionEvaluator); const router = Router(); router.use(express.json()); @@ -143,10 +162,15 @@ export async function createRouter( backstageToken: token, }); + const resourceRef = stringifyEntityRef({ kind, namespace, name }); const status = await jenkinsApi.rebuildProject( jenkinsInfo, jobFullName, parseInt(buildNumber, 10), + resourceRef, + { + token, + }, ); response.json({}).status(status); },