Re-add permission check and crumb issuer support

Signed-off-by: Tim Soslow <tsoslow@shutterstock.com>
This commit is contained in:
Tim Soslow
2023-05-10 16:56:33 -05:00
parent c81333866a
commit 3e6cc3cfe9
4 changed files with 192 additions and 6 deletions
+4
View File
@@ -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<express.Router>;
@@ -83,5 +85,7 @@ export interface RouterOptions {
jenkinsInfoProvider: JenkinsInfoProvider;
// (undocumented)
logger: Logger;
// (undocumented)
permissions?: PermissionEvaluator | PermissionAuthorizer;
}
```
@@ -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<any>;
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 <TError>(call: () => unknown): Promise<TError> => {
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<typeof fetch>;
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<any>;
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);
});
});
});
@@ -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<number> {
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<HeaderInit> {
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;
}
}
+26 -2
View File
@@ -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<express.Router> {
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);
},