Merge pull request #22591 from deepan10/azure-devops-permission
Azure Devops integrated with Permission Framework
This commit is contained in:
@@ -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.
|
||||
@@ -25,5 +25,6 @@ export default async function createPlugin(
|
||||
logger: env.logger,
|
||||
config: env.config,
|
||||
reader: env.reader,
|
||||
permissions: env.permissions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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<AzureDevOpsApi>;
|
||||
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',
|
||||
|
||||
@@ -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<express.Router> {
|
||||
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,
|
||||
|
||||
@@ -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<Server> {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -36,5 +36,9 @@
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"@backstage/plugin-catalog-common": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,3 +16,4 @@
|
||||
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
export * from './permissions';
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -210,6 +210,7 @@ export interface Team {
|
||||
export interface ReadmeConfig {
|
||||
project: string;
|
||||
repo: string;
|
||||
entityRef: string;
|
||||
host?: string;
|
||||
org?: string;
|
||||
path?: string;
|
||||
|
||||
@@ -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<Router> {
|
||||
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<PolicyDecision> {
|
||||
+ 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,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+16
-1
@@ -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 = () => <GitTagTable />;
|
||||
export const EntityPageAzureGitTags = () => {
|
||||
const { entity } = useEntity();
|
||||
return (
|
||||
<RequirePermission
|
||||
permission={azureDevOpsGitTagReadPermission}
|
||||
resourceRef={stringifyEntityRef(entity)}
|
||||
errorPage={null}
|
||||
>
|
||||
<GitTagTable />
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
+12
-1
@@ -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 <BuildTable items={items} loading={loading} error={error} />;
|
||||
return (
|
||||
<RequirePermission
|
||||
permission={azureDevOpsPipelineReadPermission}
|
||||
resourceRef={stringifyEntityRef(entity)}
|
||||
errorPage={null}
|
||||
>
|
||||
<BuildTable items={items} loading={loading} error={error} />
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
+14
-1
@@ -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 <PullRequestTable defaultLimit={props.defaultLimit} />;
|
||||
const { entity } = useEntity();
|
||||
return (
|
||||
<RequirePermission
|
||||
permission={azureDevOpsPullRequestReadPermission}
|
||||
resourceRef={stringifyEntityRef(entity)}
|
||||
errorPage={null}
|
||||
>
|
||||
<PullRequestTable defaultLimit={props.defaultLimit} />
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
<Page themeId="tool">
|
||||
<Header title="Azure Pull Requests" />
|
||||
<Content>
|
||||
<PullRequestsPageContent
|
||||
pullRequestGroups={pullRequestGroups}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
<RequirePermission
|
||||
permission={azureDevOpsPullRequestDashboardReadPermission}
|
||||
>
|
||||
<PullRequestsPageContent
|
||||
pullRequestGroups={pullRequestGroups}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
</RequirePermission>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ function isNotFoundError(error: any): boolean {
|
||||
}
|
||||
|
||||
const ReadmeCardError = ({ error }: ErrorProps) => {
|
||||
if (isNotFoundError(error))
|
||||
if (isNotFoundError(error)) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No README available for this entity"
|
||||
@@ -80,13 +80,13 @@ const ReadmeCardError = ({ error }: ErrorProps) => {
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ErrorPanel title={error.message} error={error} />;
|
||||
};
|
||||
|
||||
export const ReadmeCard = (props: Props) => {
|
||||
const classes = useStyles();
|
||||
const { entity } = useEntity();
|
||||
|
||||
const { loading, error, item: value } = useReadme(entity);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user