diff --git a/.changeset/ten-dryers-compete.md b/.changeset/ten-dryers-compete.md new file mode 100644 index 0000000000..f1591c0040 --- /dev/null +++ b/.changeset/ten-dryers-compete.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-azure-devops-backend': minor +'@backstage/plugin-azure-devops': minor +'@backstage/plugin-azure-devops-common': patch +--- + +Azure DevOps plugin is now integrated with permission framework for its core features, see the https://github.com/backstage/backstage/blob/master/plugins/azure-devops/README.md#permission-framework for more details. diff --git a/packages/backend/src/plugins/azure-devops.ts b/packages/backend/src/plugins/azure-devops.ts index ed5b1d0068..719bb0500e 100644 --- a/packages/backend/src/plugins/azure-devops.ts +++ b/packages/backend/src/plugins/azure-devops.ts @@ -25,5 +25,6 @@ export default async function createPlugin( logger: env.logger, config: env.config, reader: env.reader, + permissions: env.permissions, }); } diff --git a/plugins/azure-devops-backend/api-report.md b/plugins/azure-devops-backend/api-report.md index d98d178cff..7231509fc1 100644 --- a/plugins/azure-devops-backend/api-report.md +++ b/plugins/azure-devops-backend/api-report.md @@ -16,6 +16,7 @@ import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { GitTag } from '@backstage/plugin-azure-devops-common'; import { LocationSpec } from '@backstage/plugin-catalog-common'; import { Logger } from 'winston'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { Project } from '@backstage/plugin-azure-devops-common'; import { PullRequest } from '@backstage/plugin-azure-devops-common'; import { PullRequestOptions } from '@backstage/plugin-azure-devops-common'; @@ -160,6 +161,8 @@ export interface RouterOptions { // (undocumented) logger: Logger; // (undocumented) + permissions: PermissionEvaluator; + // (undocumented) reader: UrlReader; } ``` 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/plugin.ts b/plugins/azure-devops-backend/src/plugin.ts index ea0976a1f2..1cf17ceecc 100644 --- a/plugins/azure-devops-backend/src/plugin.ts +++ b/plugins/azure-devops-backend/src/plugin.ts @@ -34,14 +34,16 @@ export const azureDevOpsPlugin = createBackendPlugin({ config: coreServices.rootConfig, logger: coreServices.logger, reader: coreServices.urlReader, + permissions: coreServices.permissions, httpRouter: coreServices.httpRouter, }, - async init({ config, logger, reader, httpRouter }) { + async init({ config, logger, reader, permissions, httpRouter }) { httpRouter.use( await createRouter({ config, logger: loggerToWinstonLogger(logger), reader, + permissions, }), ); }, diff --git a/plugins/azure-devops-backend/src/service/router.test.ts b/plugins/azure-devops-backend/src/service/router.test.ts index c12b13a5aa..d1e9179dc1 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', @@ -516,10 +559,13 @@ describe('createRouter', () => { content, url, }); + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); - const response = await request(app).get( - '/readme/myProject/myRepo?path=README_NOT_DEFAULT.md', - ); + const response = await request(app) + .get('/readme/myProject/myRepo?path=README_NOT_DEFAULT.md') + .query({ entityRef: 'component:default/mycomponent' }); expect(azureDevOpsApi.getReadme).toHaveBeenCalledWith( 'host.com', 'myOrg', @@ -544,10 +590,13 @@ describe('createRouter', () => { content, url, }); + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); - const response = await request(app).get( - '/readme/myProject/myRepo?path=/my-path/README.md', - ); + const response = await request(app) + .get('/readme/myProject/myRepo?path=/my-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..239de78fbe 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -26,8 +26,21 @@ import { Logger } from 'winston'; import { PullRequestsDashboardProvider } from '../api/PullRequestsDashboardProvider'; 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 +50,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 +73,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 +129,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; + 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 +180,32 @@ export async function createRouter( status: status, }; + const entityRef = req.query.entityRef; + 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 +231,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 +288,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; + 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 +353,33 @@ export async function createRouter( } const { projectName, repoName } = req.params; + + const entityRef = req.query.entityRef; + 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/plugins/azure-devops-backend/src/service/standaloneServer.ts b/plugins/azure-devops-backend/src/service/standaloneServer.ts index e2f4aa4986..65b13fab35 100644 --- a/plugins/azure-devops-backend/src/service/standaloneServer.ts +++ b/plugins/azure-devops-backend/src/service/standaloneServer.ts @@ -18,10 +18,13 @@ import { createServiceBuilder, loadBackendConfig, UrlReaders, + ServerTokenManager, + HostDiscovery, } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; export interface ServerOptions { port: number; @@ -34,13 +37,23 @@ export async function startStandaloneServer( ): Promise { const logger = options.logger.child({ service: 'azure-devops-backend' }); const config = await loadBackendConfig({ logger, argv: process.argv }); + const discovery = HostDiscovery.fromConfig(config); logger.debug('Starting application server...'); + const tokenManager = ServerTokenManager.fromConfig(config, { + logger, + }); + const permissions = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + const router = await createRouter({ logger, config, reader: UrlReaders.default({ logger, config }), + permissions, }); let service = createServiceBuilder(module) diff --git a/plugins/azure-devops-common/api-report.md b/plugins/azure-devops-common/api-report.md index e8343178e3..c926823367 100644 --- a/plugins/azure-devops-common/api-report.md +++ b/plugins/azure-devops-common/api-report.md @@ -3,6 +3,9 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { BasicPermission } from '@backstage/plugin-permission-common'; +import { ResourcePermission } from '@backstage/plugin-permission-common'; + // @public (undocumented) export const AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION = 'dev.azure.com/build-definition'; @@ -22,6 +25,27 @@ export const AZURE_DEVOPS_README_ANNOTATION = 'dev.azure.com/readme-path'; // @public (undocumented) export const AZURE_DEVOPS_REPO_ANNOTATION = 'dev.azure.com/project-repo'; +// @public (undocumented) +export const azureDevOpsGitTagReadPermission: ResourcePermission<'catalog-entity'>; + +// @public (undocumented) +export const azureDevOpsPermissions: ( + | BasicPermission + | ResourcePermission<'catalog-entity'> +)[]; + +// @public (undocumented) +export const azureDevOpsPipelineReadPermission: ResourcePermission<'catalog-entity'>; + +// @public (undocumented) +export const azureDevOpsPullRequestDashboardReadPermission: BasicPermission; + +// @public (undocumented) +export const azureDevOpsPullRequestReadPermission: ResourcePermission<'catalog-entity'>; + +// @public (undocumented) +export const azureDevOpsReadmeReadPermission: ResourcePermission<'catalog-entity'>; + // @public (undocumented) export enum BuildResult { Canceled = 32, @@ -226,6 +250,8 @@ export interface Readme { // @public (undocumented) export interface ReadmeConfig { + // (undocumented) + entityRef: string; // (undocumented) host?: string; // (undocumented) diff --git a/plugins/azure-devops-common/package.json b/plugins/azure-devops-common/package.json index 23a575f95e..70b7be4964 100644 --- a/plugins/azure-devops-common/package.json +++ b/plugins/azure-devops-common/package.json @@ -36,5 +36,9 @@ }, "files": [ "dist" - ] + ], + "dependencies": { + "@backstage/plugin-catalog-common": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^" + } } diff --git a/plugins/azure-devops-common/src/index.ts b/plugins/azure-devops-common/src/index.ts index 9a4a007317..1dd5d77d51 100644 --- a/plugins/azure-devops-common/src/index.ts +++ b/plugins/azure-devops-common/src/index.ts @@ -16,3 +16,4 @@ export * from './types'; export * from './constants'; +export * from './permissions'; diff --git a/plugins/azure-devops-common/src/permissions.ts b/plugins/azure-devops-common/src/permissions.ts new file mode 100644 index 0000000000..da1515580a --- /dev/null +++ b/plugins/azure-devops-common/src/permissions.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2024 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 { createPermission } from '@backstage/plugin-permission-common'; +import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common/alpha'; + +/** + * @public + */ +export const azureDevOpsPullRequestReadPermission = createPermission({ + name: 'azure.devops.pullrequest.read', + attributes: { action: 'read' }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, +}); + +/** + * @public + */ +export const azureDevOpsPullRequestDashboardReadPermission = createPermission({ + name: 'azure.devops.pullrequest.dashboard.read', + attributes: { action: 'read' }, +}); + +/** + * @public + */ +export const azureDevOpsPipelineReadPermission = createPermission({ + name: 'azure.devops.pipeline.read', + attributes: { action: 'read' }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, +}); + +/** + * @public + */ +export const azureDevOpsGitTagReadPermission = createPermission({ + name: 'azure.devops.gittag.read', + attributes: { action: 'read' }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, +}); + +/** + * @public + */ +export const azureDevOpsReadmeReadPermission = createPermission({ + name: 'azure.devops.readme.read', + attributes: { action: 'read' }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, +}); + +/** + * @public + */ +export const azureDevOpsPermissions = [ + azureDevOpsPullRequestReadPermission, + azureDevOpsPipelineReadPermission, + azureDevOpsGitTagReadPermission, + azureDevOpsReadmeReadPermission, + azureDevOpsPullRequestDashboardReadPermission, +]; diff --git a/plugins/azure-devops-common/src/types.ts b/plugins/azure-devops-common/src/types.ts index 0390bb1f6e..ab53af66cb 100644 --- a/plugins/azure-devops-common/src/types.ts +++ b/plugins/azure-devops-common/src/types.ts @@ -210,6 +210,7 @@ export interface Team { export interface ReadmeConfig { project: string; repo: string; + entityRef: string; host?: string; org?: string; path?: string; diff --git a/plugins/azure-devops/README.md b/plugins/azure-devops/README.md index 935e78c82c..939330812e 100644 --- a/plugins/azure-devops/README.md +++ b/plugins/azure-devops/README.md @@ -319,3 +319,81 @@ To get the README component working you'll need to do the following two steps: - You'll need to add the `EntitySwitch.Case` above from step 2 to all the entity sections you want to see Readme in. For example if you wanted to see Readme when looking at Website entities then you would need to add this to the `websiteEntityPage` section. - The `if` prop is optional on the `EntitySwitch.Case`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation - The `maxHeight` property on the `EntityAzureReadmeCard` will set the maximum screen size you would like to see, if not set it will default to 100% + +## Permission Framework + +Azure DevOps plugin supports the permission framework for PRs, GitTags, Pipelines and Readme features. + +```bash +# From your Backstage root directory +yarn add --cwd packages/backend @backstage/plugin-azure-devops-common +``` + +New Backend you can skip the below and proceed with [permission configuration](#configure-permission) + +To enable permissions for the legacy backend system in `packages/backend/src/plugins/azure-devops.ts` add the following. + +```diff +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return createRouter({ + logger: env.logger, + config: env.config, + reader: env.reader, ++ permissions: env.permissions, + }); +} +``` + +### Configure Permission + +To apply the permission rules add the following in `packages/backend/src/plugins/permissions.ts`. + +> Note: the following is just an example of how you might want to setup permissions, as an Adopter you can configure this to fit your needs. Also all the permissions are Resource Permissions as they work with an Entity with the exception of `azureDevOpsPullRequestDashboardReadPermission`. + +```diff + ++ import { ++ azureDevOpsPullRequestReadPermission, ++ azureDevOpsPipelineReadPermission, ++ azureDevOpsGitTagReadPermission, ++ azureDevOpsReadmeReadPermission, ++ azureDevOpsPullRequestDashboardReadPermission } from '@backstage/plugin-azure-devops-common'; ++ import { ++ AuthorizeResult, ++ PolicyDecision, ++ isPermission, ++ } from '@backstage/plugin-permission-common'; ++ import { ++ catalogConditions, ++ createCatalogConditionalDecision, ++ } from '@backstage/plugin-catalog-backend/alpha'; +... +async handle( + request: PolicyQuery, + user?: BackstageIdentityResponse, +): Promise { ++ if ( isPermission(request.permission, azureDevOpsPullRequestReadPermission) || ++ isPermission(request.permission, azureDevOpsPipelineReadPermission) || ++ isPermission(request.permission, azureDevOpsGitTagReadPermission) || ++ isPermission(request.permission, azureDevOpsReadmeReadPermission)) { ++ return createCatalogConditionalDecision( ++ request.permission, ++ catalogConditions.isEntityOwner({ ++ claims: user?.identity.ownershipEntityRefs ?? [], ++ }), ++ ); ++ } + ++ if ( isPermission(request.permission, azureDevOpsPullRequestDashboardReadPermission) { ++ return { ++ result: AuthorizeResult.ALLOW, ++ }; ++ } + + return { + result: AuthorizeResult.ALLOW, + }; +} +``` diff --git a/plugins/azure-devops/api-report.md b/plugins/azure-devops/api-report.md index e4e2d4306f..e76e172d9b 100644 --- a/plugins/azure-devops/api-report.md +++ b/plugins/azure-devops/api-report.md @@ -69,6 +69,7 @@ export interface AzureDevOpsApi { // (undocumented) getBuildRuns( projectName: string, + entityRef: string, repoName?: string, definitionName?: string, host?: string, @@ -85,6 +86,7 @@ export interface AzureDevOpsApi { getGitTags( projectName: string, repoName: string, + entityRef: string, host?: string, org?: string, ): Promise<{ @@ -94,6 +96,7 @@ export interface AzureDevOpsApi { getPullRequests( projectName: string, repoName: string, + entityRef: string, host?: string, org?: string, options?: PullRequestOptions, @@ -127,6 +130,7 @@ export class AzureDevOpsClient implements AzureDevOpsApi { // (undocumented) getBuildRuns( projectName: string, + entityRef: string, repoName?: string, definitionName?: string, host?: string, @@ -143,6 +147,7 @@ export class AzureDevOpsClient implements AzureDevOpsApi { getGitTags( projectName: string, repoName: string, + entityRef: string, host?: string, org?: string, ): Promise<{ @@ -152,6 +157,7 @@ export class AzureDevOpsClient implements AzureDevOpsApi { getPullRequests( projectName: string, repoName: string, + entityRef: string, host?: string, org?: string, options?: PullRequestOptions, diff --git a/plugins/azure-devops/package.json b/plugins/azure-devops/package.json index 12860d82cc..6e72c4e45d 100644 --- a/plugins/azure-devops/package.json +++ b/plugins/azure-devops/package.json @@ -50,6 +50,7 @@ "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-azure-devops-common": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", + "@backstage/plugin-permission-react": "workspace:^", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0", diff --git a/plugins/azure-devops/src/api/AzureDevOpsApi.ts b/plugins/azure-devops/src/api/AzureDevOpsApi.ts index 89aa4b7d6e..6dd6eeae6b 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsApi.ts @@ -48,6 +48,7 @@ export interface AzureDevOpsApi { getGitTags( projectName: string, repoName: string, + entityRef: string, host?: string, org?: string, ): Promise<{ items: GitTag[] }>; @@ -55,6 +56,7 @@ export interface AzureDevOpsApi { getPullRequests( projectName: string, repoName: string, + entityRef: string, host?: string, org?: string, options?: PullRequestOptions, @@ -70,6 +72,7 @@ export interface AzureDevOpsApi { getBuildRuns( projectName: string, + entityRef: string, repoName?: string, definitionName?: string, host?: string, diff --git a/plugins/azure-devops/src/api/AzureDevOpsClient.ts b/plugins/azure-devops/src/api/AzureDevOpsClient.ts index f5c046ef07..ea78f3d3a1 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsClient.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsClient.ts @@ -72,6 +72,7 @@ export class AzureDevOpsClient implements AzureDevOpsApi { public async getGitTags( projectName: string, repoName: string, + entityRef: string, host?: string, org?: string, ): Promise<{ items: GitTag[] }> { @@ -82,6 +83,7 @@ export class AzureDevOpsClient implements AzureDevOpsApi { if (org) { queryString.append('org', org); } + queryString.append('entityRef', entityRef); const urlSegment = `git-tags/${encodeURIComponent( projectName, )}/${encodeURIComponent(repoName)}?${queryString}`; @@ -93,6 +95,7 @@ export class AzureDevOpsClient implements AzureDevOpsApi { public async getPullRequests( projectName: string, repoName: string, + entityRef: string, host?: string, org?: string, options?: PullRequestOptions, @@ -110,6 +113,7 @@ export class AzureDevOpsClient implements AzureDevOpsApi { if (org) { queryString.append('org', org); } + queryString.append('entityRef', entityRef); const urlSegment = `pull-requests/${encodeURIComponent( projectName, )}/${encodeURIComponent(repoName)}?${queryString}`; @@ -136,6 +140,7 @@ export class AzureDevOpsClient implements AzureDevOpsApi { public async getBuildRuns( projectName: string, + entityRef: string, repoName?: string, definitionName?: string, host?: string, @@ -174,6 +179,7 @@ export class AzureDevOpsClient implements AzureDevOpsApi { if (options?.top) { queryString.append('top', options.top.toString()); } + queryString.append('entityRef', entityRef); const urlSegment = `builds/${encodeURIComponent( projectName, )}?${queryString}`; @@ -192,6 +198,7 @@ export class AzureDevOpsClient implements AzureDevOpsApi { if (opts.path) { queryString.append('path', opts.path); } + queryString.append('entityRef', opts.entityRef); return await this.get( `readme/${encodeURIComponent(opts.project)}/${encodeURIComponent( opts.repo, diff --git a/plugins/azure-devops/src/components/EntityPageAzureGitTags/EntityPageAzureGitTags.tsx b/plugins/azure-devops/src/components/EntityPageAzureGitTags/EntityPageAzureGitTags.tsx index 2d94d735df..3e174fd31b 100644 --- a/plugins/azure-devops/src/components/EntityPageAzureGitTags/EntityPageAzureGitTags.tsx +++ b/plugins/azure-devops/src/components/EntityPageAzureGitTags/EntityPageAzureGitTags.tsx @@ -14,7 +14,22 @@ * limitations under the License. */ +import { azureDevOpsGitTagReadPermission } from '@backstage/plugin-azure-devops-common'; import { GitTagTable } from '../GitTagTable/GitTagTable'; import React from 'react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { RequirePermission } from '@backstage/plugin-permission-react'; -export const EntityPageAzureGitTags = () => ; +export const EntityPageAzureGitTags = () => { + const { entity } = useEntity(); + return ( + + + + ); +}; diff --git a/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx b/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx index c33d849c24..d8c6483a3e 100644 --- a/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx +++ b/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx @@ -18,11 +18,22 @@ import { BuildTable } from '../BuildTable/BuildTable'; import React from 'react'; import { useBuildRuns } from '../../hooks'; import { useEntity } from '@backstage/plugin-catalog-react'; +import { azureDevOpsPipelineReadPermission } from '@backstage/plugin-azure-devops-common'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { RequirePermission } from '@backstage/plugin-permission-react'; export const EntityPageAzurePipelines = (props: { defaultLimit?: number }) => { const { entity } = useEntity(); const { items, loading, error } = useBuildRuns(entity, props.defaultLimit); - return ; + return ( + + + + ); }; diff --git a/plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx index b5a21ff750..1d59ea4b8f 100644 --- a/plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx +++ b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx @@ -14,11 +14,24 @@ * limitations under the License. */ +import { azureDevOpsPullRequestReadPermission } from '@backstage/plugin-azure-devops-common'; import { PullRequestTable } from '../PullRequestTable/PullRequestTable'; import React from 'react'; +import { RequirePermission } from '@backstage/plugin-permission-react'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; export const EntityPageAzurePullRequests = (props: { defaultLimit?: number; }) => { - return ; + const { entity } = useEntity(); + return ( + + + + ); }; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx b/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx index c5da207bd6..5971a03afd 100644 --- a/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx +++ b/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx @@ -28,6 +28,8 @@ import { FilterType } from './lib/filters'; import { PullRequestGrid } from './lib/PullRequestGrid'; import { useDashboardPullRequests } from '../../hooks'; import { useFilterProcessor } from './lib/hooks'; +import { RequirePermission } from '@backstage/plugin-permission-react'; +import { azureDevOpsPullRequestDashboardReadPermission } from '@backstage/plugin-azure-devops-common'; type PullRequestsPageContentProps = { pullRequestGroups: PullRequestGroup[] | undefined; @@ -98,11 +100,15 @@ export const PullRequestsPage = (props: PullRequestsPageProps) => {
- + + + ); diff --git a/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx b/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx index 300c9e0d7c..5443d9232b 100644 --- a/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx +++ b/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx @@ -63,7 +63,7 @@ function isNotFoundError(error: any): boolean { } const ReadmeCardError = ({ error }: ErrorProps) => { - if (isNotFoundError(error)) + if (isNotFoundError(error)) { return ( { } /> ); + } return ; }; export const ReadmeCard = (props: Props) => { const classes = useStyles(); const { entity } = useEntity(); - const { loading, error, item: value } = useReadme(entity); if (loading) { diff --git a/plugins/azure-devops/src/hooks/useBuildRuns.ts b/plugins/azure-devops/src/hooks/useBuildRuns.ts index e335d2ffaf..21f4d60430 100644 --- a/plugins/azure-devops/src/hooks/useBuildRuns.ts +++ b/plugins/azure-devops/src/hooks/useBuildRuns.ts @@ -24,7 +24,7 @@ import { azureDevOpsApiRef } from '../api'; import { useApi } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; import { getAnnotationValuesFromEntity } from '../utils'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; export function useBuildRuns( entity: Entity, @@ -44,7 +44,15 @@ export function useBuildRuns( const { value, loading, error } = useAsync(() => { const { project, repo, definition, host, org } = getAnnotationValuesFromEntity(entity); - return api.getBuildRuns(project, repo, definition, host, org, options); + return api.getBuildRuns( + project, + stringifyEntityRef(entity), + repo, + definition, + host, + org, + options, + ); }, [api]); return { diff --git a/plugins/azure-devops/src/hooks/useGitTags.ts b/plugins/azure-devops/src/hooks/useGitTags.ts index 57489316da..3cd65dad92 100644 --- a/plugins/azure-devops/src/hooks/useGitTags.ts +++ b/plugins/azure-devops/src/hooks/useGitTags.ts @@ -16,7 +16,7 @@ import { GitTag } from '@backstage/plugin-azure-devops-common'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { azureDevOpsApiRef } from '../api'; import { useApi } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; @@ -31,7 +31,13 @@ export function useGitTags(entity: Entity): { const { value, loading, error } = useAsync(() => { const { project, repo, host, org } = getAnnotationValuesFromEntity(entity); - return api.getGitTags(project, repo as string, host, org); + return api.getGitTags( + project, + repo as string, + stringifyEntityRef(entity), + host, + org, + ); }, [api]); return { diff --git a/plugins/azure-devops/src/hooks/usePullRequests.ts b/plugins/azure-devops/src/hooks/usePullRequests.ts index af2c4b1cc1..7347f66156 100644 --- a/plugins/azure-devops/src/hooks/usePullRequests.ts +++ b/plugins/azure-devops/src/hooks/usePullRequests.ts @@ -21,7 +21,7 @@ import { PullRequestStatus, } from '@backstage/plugin-azure-devops-common'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { azureDevOpsApiRef } from '../api'; import { useApi } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; @@ -47,7 +47,15 @@ export function usePullRequests( const { value, loading, error } = useAsync(() => { const { project, repo, host, org } = getAnnotationValuesFromEntity(entity); - return api.getPullRequests(project, repo as string, host, org, options); + const entityRef = stringifyEntityRef(entity); + return api.getPullRequests( + project, + repo as string, + entityRef, + host, + org, + options, + ); }, [api, top, status]); return { diff --git a/plugins/azure-devops/src/hooks/useReadme.ts b/plugins/azure-devops/src/hooks/useReadme.ts index 9aa376e0bd..cfc7f04bf4 100644 --- a/plugins/azure-devops/src/hooks/useReadme.ts +++ b/plugins/azure-devops/src/hooks/useReadme.ts @@ -16,7 +16,7 @@ import { Readme } from '@backstage/plugin-azure-devops-common'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { azureDevOpsApiRef } from '../api'; import { useApi } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; @@ -32,9 +32,11 @@ export function useReadme(entity: Entity): { const { value, loading, error } = useAsync(() => { const { project, repo, host, org, readmePath } = getAnnotationValuesFromEntity(entity); + const entityRef = stringifyEntityRef(entity); return api.getReadme({ project, repo: repo as string, + entityRef, host, org, path: readmePath, diff --git a/yarn.lock b/yarn.lock index 9571717343..8735faae59 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 @@ -5159,6 +5164,7 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-azure-devops-common": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" + "@backstage/plugin-permission-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@material-ui/core": ^4.12.2 "@material-ui/icons": ^4.9.1