diff --git a/.changeset/gold-garlics-sing.md b/.changeset/gold-garlics-sing.md new file mode 100644 index 0000000000..1162cca2d4 --- /dev/null +++ b/.changeset/gold-garlics-sing.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins-common': minor +--- + +Add a new common plugin for Jenkins which provides shared isomorphic code for the Jenkins plugin. diff --git a/.changeset/orange-cobras-shave.md b/.changeset/orange-cobras-shave.md new file mode 100644 index 0000000000..ad76443c8c --- /dev/null +++ b/.changeset/orange-cobras-shave.md @@ -0,0 +1,12 @@ +--- +'@backstage/plugin-jenkins-backend': patch +--- + +Jenkins plugin supports permissions now. We have added a new permission, so you can manage the permission for the users. +A new permission `jenkinsExecutePermission` is provided in `jenkins-common` package. This permission rule will be applied to check rebuild actions +if user is allowed to execute this action. + +> We use 'catalog-entity' as a resource type, so you need to integrate a policy to handle catalog-entity resources + +> You need to use this permission in your permission policy to check the user role/rights and return +> `AuthorizeResult.ALLOW` to allow rebuild action to logged user. (e.g: you can check if user or related group owns the entity) diff --git a/.changeset/ten-fireants-march.md b/.changeset/ten-fireants-march.md new file mode 100644 index 0000000000..8283eef5d6 --- /dev/null +++ b/.changeset/ten-fireants-march.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-jenkins': minor +--- + +Jenkins plugin supports permissions now. We have added a new permission, so you can manage the permission for the users. See relates notes for `jenkins-plugin` for more details. + +Rebuild action will be disabled if the user does not have necessary rights to execute rebuild action. A permission policy (defined in backend) must handle and check the identity rights +and return `AuthorizeResult.ALLOW` if user is allowed to execute rebuild action. diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index cbc3ef4325..264d3912c8 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -8,6 +8,7 @@ import { CompoundEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger as Logger_2 } from 'winston'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -94,5 +95,7 @@ export interface RouterOptions { jenkinsInfoProvider: JenkinsInfoProvider; // (undocumented) logger: Logger_2; + // (undocumented) + permissions?: PermissionAuthorizer; } ``` diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json index 66cbe5ba24..14005656ab 100644 --- a/plugins/jenkins-backend/package.json +++ b/plugins/jenkins-backend/package.json @@ -29,6 +29,10 @@ "@backstage/catalog-client": "^0.7.2", "@backstage/catalog-model": "^0.11.0", "@backstage/config": "^0.1.15", + "@backstage/errors": "^0.2.2", + "@backstage/plugin-auth-node": "^0.1.3", + "@backstage/plugin-jenkins-common": "^0.0.0", + "@backstage/plugin-permission-common": "^0.5.1", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index 683b9334bc..45f7424e40 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -18,6 +18,8 @@ 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 { NotAllowedError } from '@backstage/errors'; jest.mock('jenkins'); const mockedJenkinsClient = { @@ -40,8 +42,16 @@ const jenkinsInfo: JenkinsInfo = { jobFullName: 'example-jobName', }; +const fakePermissionApi = { + authorize: jest.fn().mockResolvedValue([ + { + result: AuthorizeResult.ALLOW, + }, + ]), +}; + describe('JenkinsApi', () => { - const jenkinsApi = new JenkinsApiImpl(); + const jenkinsApi = new JenkinsApiImpl(fakePermissionApi); describe('getProjects', () => { const project: JenkinsProject = { @@ -413,6 +423,34 @@ describe('JenkinsApi', () => { expect(mockedJenkinsClient.job.build).toBeCalledWith(jobFullName); }); + it('buildProject should fail if it does not have required permissions', async () => { + fakePermissionApi.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.DENY, + }, + ]); + + await expect(() => + jenkinsApi.buildProject(jenkinsInfo, jobFullName), + ).rejects.toThrow(NotAllowedError); + }); + + it('buildProject should succeed if it have required permissions', async () => { + fakePermissionApi.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.ALLOW, + }, + ]); + + await jenkinsApi.buildProject(jenkinsInfo, jobFullName); + expect(mockedJenkins).toHaveBeenCalledWith({ + baseUrl: jenkinsInfo.baseUrl, + headers: jenkinsInfo.headers, + promisify: true, + }); + expect(mockedJenkinsClient.job.build).toBeCalledWith(jobFullName); + }); + it('buildProject with crumbIssuer option', async () => { const info: JenkinsInfo = { ...jenkinsInfo, crumbIssuer: true }; await jenkinsApi.buildProject(info, jobFullName); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 3dbba13db6..da16bbc553 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -14,15 +14,21 @@ * limitations under the License. */ -import { JenkinsInfo } from './jenkinsInfoProvider'; +import type { JenkinsInfo } from './jenkinsInfoProvider'; import jenkins from 'jenkins'; -import { +import type { BackstageBuild, BackstageProject, JenkinsBuild, JenkinsProject, ScmDetails, } from '../types'; +import { + AuthorizeResult, + PermissionAuthorizer, +} from '@backstage/plugin-permission-common'; +import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common'; +import { NotAllowedError } from '@backstage/errors'; export class JenkinsApiImpl { private static readonly lastBuildTreeSpec = `lastBuild[ @@ -58,6 +64,8 @@ export class JenkinsApiImpl { ${JenkinsApiImpl.jobTreeSpec} ]{0,50}`; + constructor(private readonly permissionApi?: PermissionAuthorizer) {} + /** * Get a list of projects for the given JenkinsInfo. * @see ../../../jenkins/src/api/JenkinsApi.ts#getProjects @@ -128,9 +136,26 @@ export class JenkinsApiImpl { * Trigger a build of a project * @see ../../../jenkins/src/api/JenkinsApi.ts#retry */ - async buildProject(jenkinsInfo: JenkinsInfo, jobFullName: string) { + async buildProject( + jenkinsInfo: JenkinsInfo, + jobFullName: string, + resourceRef?: string, + options?: { token?: string }, + ) { const client = await JenkinsApiImpl.getClient(jenkinsInfo); + 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) { + throw new NotAllowedError(); + } + } + // looks like the current SDK only supports triggering a new build // can't see any support for replay (re-running the specific build with the same SCM info) diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 8c5852efa9..08712215e4 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -20,10 +20,14 @@ import Router from 'express-promise-router'; import { Logger } from 'winston'; import { JenkinsInfoProvider } from './jenkinsInfoProvider'; import { JenkinsApiImpl } from './jenkinsApi'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; +import { stringifyEntityRef } from '@backstage/catalog-model'; export interface RouterOptions { logger: Logger; jenkinsInfoProvider: JenkinsInfoProvider; + permissions?: PermissionAuthorizer; } export async function createRouter( @@ -31,7 +35,7 @@ export async function createRouter( ): Promise { const { jenkinsInfoProvider } = options; - const jenkinsApi = new JenkinsApiImpl(); + const jenkinsApi = new JenkinsApiImpl(options.permissions); const router = Router(); router.use(express.json()); @@ -103,7 +107,6 @@ export async function createRouter( '/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber::rebuild', async (request, response) => { const { namespace, kind, name, jobFullName } = request.params; - const jenkinsInfo = await jenkinsInfoProvider.getInstance({ entityRef: { kind, @@ -112,12 +115,17 @@ export async function createRouter( }, jobFullName, }); + const token = getBearerTokenFromAuthorizationHeader( + request.header('authorization'), + ); - await jenkinsApi.buildProject(jenkinsInfo, jobFullName); + const resourceRef = stringifyEntityRef({ kind, namespace, name }); + await jenkinsApi.buildProject(jenkinsInfo, jobFullName, resourceRef, { + token, + }); response.json({}); }, ); - router.use(errorHandler()); return router; } diff --git a/plugins/jenkins-common/.eslintrc.js b/plugins/jenkins-common/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/jenkins-common/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/jenkins-common/README.md b/plugins/jenkins-common/README.md new file mode 100644 index 0000000000..54a79fc287 --- /dev/null +++ b/plugins/jenkins-common/README.md @@ -0,0 +1,3 @@ +# Jenkins Common + +Shared isomorphic code for the Jenkins plugin. diff --git a/plugins/jenkins-common/api-report.md b/plugins/jenkins-common/api-report.md new file mode 100644 index 0000000000..7b0d3759ba --- /dev/null +++ b/plugins/jenkins-common/api-report.md @@ -0,0 +1,12 @@ +## API Report File for "@backstage/plugin-jenkins-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Permission } from '@backstage/plugin-permission-common'; + +// @public +export const jenkinsExecutePermission: Permission; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/jenkins-common/package.json b/plugins/jenkins-common/package.json new file mode 100644 index 0000000000..41a63298ef --- /dev/null +++ b/plugins/jenkins-common/package.json @@ -0,0 +1,34 @@ +{ + "name": "@backstage/plugin-jenkins-common", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "common-library" + }, + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "clean": "backstage-cli package clean" + }, + "dependencies": { + "@backstage/plugin-catalog-common": "^0.1.4", + "@backstage/plugin-permission-common": "^0.5.1" + }, + "devDependencies": { + "@backstage/cli": "^0.14.1" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/jenkins-common/src/index.ts b/plugins/jenkins-common/src/index.ts new file mode 100644 index 0000000000..aff3b136bd --- /dev/null +++ b/plugins/jenkins-common/src/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './permissions'; diff --git a/plugins/jenkins-common/src/permissions.ts b/plugins/jenkins-common/src/permissions.ts new file mode 100644 index 0000000000..2fa8c14232 --- /dev/null +++ b/plugins/jenkins-common/src/permissions.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common'; +import { Permission } from '@backstage/plugin-permission-common'; + +/** + * This permission is used to determine if a user is allowed to execute an action in jenkins plugin + * + * @public + */ +export const jenkinsExecutePermission: Permission = { + name: 'jenkins.execute', + attributes: { + action: 'update', + }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, +}; diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index ada061dba9..74e839f7d1 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -40,6 +40,7 @@ "@backstage/core-plugin-api": "^0.7.0", "@backstage/errors": "^0.2.2", "@backstage/plugin-catalog-react": "^0.7.0", + "@backstage/plugin-jenkins-common": "^0.0.0", "@backstage/theme": "^0.2.15", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index 2c464491c2..c525e297be 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -15,6 +15,7 @@ */ import { Link, Progress, Table, TableColumn } from '@backstage/core-components'; import { alertApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { useEntityPermission } from '@backstage/plugin-catalog-react'; import { Box, IconButton, Tooltip, Typography } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import { default as React, useState } from 'react'; @@ -23,6 +24,7 @@ import JenkinsLogo from '../../../../assets/JenkinsLogo.svg'; import { buildRouteRef } from '../../../../plugin'; import { useBuilds } from '../../../useBuilds'; import { JenkinsRunStatus } from '../Status'; +import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common'; const FailCount = ({ count }: { count: number }): JSX.Element | null => { if (count !== 0) { @@ -174,6 +176,10 @@ const generatedColumns: TableColumn[] = [ render: (row: Partial) => { const ActionWrapper = () => { const [isLoadingRebuild, setIsLoadingRebuild] = useState(false); + const { allowed, loading } = useEntityPermission( + jenkinsExecutePermission, + ); + const alertApi = useApi(alertApiRef); const onRebuild = async () => { @@ -201,7 +207,7 @@ const generatedColumns: TableColumn[] = [ <> {isLoadingRebuild && } {!isLoadingRebuild && ( - + )}