diff --git a/plugins/azure-devops-backend/package.json b/plugins/azure-devops-backend/package.json index 9741177692..cc839412bf 100644 --- a/plugins/azure-devops-backend/package.json +++ b/plugins/azure-devops-backend/package.json @@ -34,9 +34,12 @@ "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-azure-devops-common": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", + "@backstage/plugin-permission-node": "workspace:^", "@types/express": "^4.17.6", "azure-devops-node-api": "^12.0.0", "express": "^4.17.1", diff --git a/plugins/azure-devops-backend/src/service/router.test.ts b/plugins/azure-devops-backend/src/service/router.test.ts index c12b13a5aa..9f874923fd 100644 --- a/plugins/azure-devops-backend/src/service/router.test.ts +++ b/plugins/azure-devops-backend/src/service/router.test.ts @@ -35,10 +35,26 @@ import { createRouter } from './router'; import express from 'express'; import { getVoidLogger, UrlReaders } from '@backstage/backend-common'; import request from 'supertest'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; describe('createRouter', () => { let azureDevOpsApi: jest.Mocked; let app: express.Express; + const mockedAuthorize = jest + .fn() + .mockImplementation(async () => [{ result: AuthorizeResult.ALLOW }]); + const mockedAuthorizeConditional = jest + .fn() + .mockImplementation(async () => [{ result: AuthorizeResult.ALLOW }]); + + const mockPermissionEvaluator = { + authorize: mockedAuthorize, + authorizeConditional: mockedAuthorizeConditional, + }; + + jest.mock('@backstage/plugin-auth-node', () => ({ + getBearerTokenFromAuthorizationHeader: () => 'token', + })); beforeAll(async () => { azureDevOpsApi = { @@ -75,6 +91,7 @@ describe('createRouter', () => { config, logger, }), + permissions: mockPermissionEvaluator, }); app = express().use(router); @@ -251,7 +268,13 @@ describe('createRouter', () => { azureDevOpsApi.getGitTags.mockResolvedValueOnce(gitTags); - const response = await request(app).get('/git-tags/myProject/myRepo'); + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + + const response = await request(app) + .get('/git-tags/myProject/myRepo') + .query({ entityRef: 'component:default/mycomponent' }); expect(azureDevOpsApi.getGitTags).toHaveBeenCalledWith( 'myProject', @@ -311,10 +334,15 @@ describe('createRouter', () => { thirdPullRequest, ]; + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + azureDevOpsApi.getPullRequests.mockResolvedValueOnce(pullRequests); const response = await request(app) .get('/pull-requests/myProject/myRepo') + .query({ entityRef: 'component:default/mycomponent' }) .query({ top: '50', status: 1 }); expect(azureDevOpsApi.getPullRequests).toHaveBeenCalledWith( @@ -398,8 +426,13 @@ describe('createRouter', () => { azureDevOpsApi.getBuildRuns.mockResolvedValueOnce(buildRuns); + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + const response = await request(app) .get('/builds/myProject') + .query({ entityRef: 'component:default/mycomponent' }) .query({ top: '50', repoName: 'myRepo' }); expect(azureDevOpsApi.getBuildRuns).toHaveBeenCalledWith( @@ -453,10 +486,15 @@ describe('createRouter', () => { thirdBuildRun, ]; + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + azureDevOpsApi.getBuildRuns.mockResolvedValueOnce(buildRuns); const response = await request(app) .get('/builds/myProject') + .query({ entityRef: 'component:default/mycomponent' }) .query({ top: '50', definitionName: 'myDefinition' }); expect(azureDevOpsApi.getBuildRuns).toHaveBeenCalledWith( @@ -490,8 +528,13 @@ describe('createRouter', () => { content, url, }); + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); - const response = await request(app).get('/readme/myProject/myRepo'); + const response = await request(app) + .get('/readme/myProject/myRepo?path=README.md') + .query({ entityRef: 'component:default/mycomponent' }); expect(azureDevOpsApi.getReadme).toHaveBeenCalledWith( 'host.com', 'myOrg', diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index bc13a4eee2..a77aa42c8a 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -28,6 +28,20 @@ import Router from 'express-promise-router'; import { errorHandler, UrlReader } from '@backstage/backend-common'; import { InputError } from '@backstage/errors'; import express from 'express'; +import { InputError, NotAllowedError } from '@backstage/errors'; +import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; +import { + PermissionEvaluator, + AuthorizeResult, +} from '@backstage/plugin-permission-common'; +import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; +import { + azureDevOpsPullRequestReadPermission, + azureDevOpsPermissions, + azureDevOpsPullRequestDashboardReadPermission, + azureDevOpsGitTagReadPermission, + azureDevOpsPipelineReadPermission, +} from '@backstage/plugin-azure-devops-common'; const DEFAULT_TOP = 10; @@ -37,13 +51,18 @@ export interface RouterOptions { logger: Logger; config: Config; reader: UrlReader; + permissions: PermissionEvaluator; } /** @public */ export async function createRouter( options: RouterOptions, ): Promise { - const { logger, reader, config } = options; + const { logger, reader, config, permissions } = options; + + const permissionIntegrationRouter = createPermissionIntegrationRouter({ + permissions: azureDevOpsPermissions, + }); const azureDevOpsApi = options.azureDevOpsApi || @@ -55,6 +74,8 @@ export async function createRouter( const router = Router(); router.use(express.json()); + router.use(permissionIntegrationRouter); + router.get('/health', (_req, res) => { res.status(200).json({ status: 'ok' }); }); @@ -109,6 +130,33 @@ export async function createRouter( const { projectName, repoName } = req.params; const host = req.query.host?.toString(); const org = req.query.org?.toString(); + + const entityRef = req.query.entityRef?.toString(); + if (typeof entityRef !== 'string') { + throw new InputError('Invalid entityRef, not a string'); + } + + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + const decision = ( + await permissions.authorize( + [ + { + permission: azureDevOpsGitTagReadPermission, + resourceRef: entityRef, + }, + ], + { + token, + }, + ) + )[0]; + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + const gitTags = await azureDevOpsApi.getGitTags( projectName, repoName, @@ -133,6 +181,32 @@ export async function createRouter( status: status, }; + const entityRef = req.query.entityRef?.toString(); + if (typeof entityRef !== 'string') { + throw new InputError('Invalid entityRef, not a string'); + } + + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + const decision = ( + await permissions.authorize( + [ + { + permission: azureDevOpsPullRequestReadPermission, + resourceRef: entityRef, + }, + ], + { + token, + }, + ) + )[0]; + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + const gitPullRequest = await azureDevOpsApi.getPullRequests( projectName, repoName, @@ -158,6 +232,26 @@ export async function createRouter( status: status, }; + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + const decision = ( + await permissions.authorize( + [ + { + permission: azureDevOpsPullRequestDashboardReadPermission, + }, + ], + { + token, + }, + ) + )[0]; + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + const pullRequests: DashboardPullRequest[] = await pullRequestsDashboardProvider.getDashboardPullRequests( projectName, @@ -195,6 +289,33 @@ export async function createRouter( const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; const host = req.query.host?.toString(); const org = req.query.org?.toString(); + + const entityRef = req.query.entityRef?.toString(); + if (typeof entityRef !== 'string') { + throw new InputError('Invalid entityRef, not a string'); + } + + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + const decision = ( + await permissions.authorize( + [ + { + permission: azureDevOpsPipelineReadPermission, + resourceRef: entityRef, + }, + ], + { + token, + }, + ) + )[0]; + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + const builds = await azureDevOpsApi.getBuildRuns( projectName, top, @@ -233,6 +354,33 @@ export async function createRouter( } const { projectName, repoName } = req.params; + + const entityRef = req.query.entityRef?.toString(); + if (typeof entityRef !== 'string') { + throw new InputError('Invalid entityRef, not a string'); + } + + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + const decision = ( + await permissions.authorize( + [ + { + permission: azureDevOpsPullRequestReadPermission, + resourceRef: entityRef, + }, + ], + { + token, + }, + ) + )[0]; + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + const readme = await azureDevOpsApi.getReadme( host, org, diff --git a/yarn.lock b/yarn.lock index 3c08b1e93e..d0b27cb311 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5120,9 +5120,12 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-azure-devops-common": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" + "@backstage/plugin-permission-node": "workspace:^" "@types/express": ^4.17.6 "@types/supertest": ^2.0.8 azure-devops-node-api: ^12.0.0 @@ -5142,6 +5145,8 @@ __metadata: resolution: "@backstage/plugin-azure-devops-common@workspace:plugins/azure-devops-common" dependencies: "@backstage/cli": "workspace:^" + "@backstage/plugin-catalog-common": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" languageName: unknown linkType: soft