Merge pull request #24518 from Zaperex/add-additional-scaffolder-permissions
Add additional scaffolder permissions
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': patch
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
updated the ContextMenu, ActionsPage, OngoingTask and TemplateCard frontend components to support the new scaffolder permissions:
|
||||
|
||||
- `scaffolder.task.create`
|
||||
- `scaffolder.task.cancel`
|
||||
- `scaffolder.task.read`
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
'@backstage/plugin-scaffolder-common': patch
|
||||
---
|
||||
|
||||
added the following new permissions to the scaffolder backend endpoints:
|
||||
|
||||
- `scaffolder.task.create`
|
||||
- `scaffolder.task.cancel`
|
||||
- `scaffolder.task.read`
|
||||
+62
-5
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: authorizing-parameters-steps-and-actions
|
||||
title: 'Authorizing parameters, steps and actions'
|
||||
description: How to authorize part of a template
|
||||
id: authorizing-scaffolder-template-details
|
||||
title: 'Authorizing scaffolder tasks, parameters, steps, and actions'
|
||||
description: How to authorize parts of a template and authorize scaffolder task access
|
||||
---
|
||||
|
||||
The scaffolder plugin integrates with the Backstage [permission framework](../../permissions/overview.md), which allows you to control access to certain parameters and steps in your templates based on the user executing the template.
|
||||
The scaffolder plugin integrates with the Backstage [permission framework](../../permissions/overview.md), which allows you to control access to certain parameters and steps in your templates based on the user executing the template. It also allows you to control access to scaffolder tasks.
|
||||
|
||||
### Authorizing parameters and steps
|
||||
|
||||
@@ -174,7 +174,64 @@ class ExamplePermissionPolicy implements PermissionPolicy {
|
||||
}
|
||||
```
|
||||
|
||||
Although the rules exported by the scaffolder are simple, combining them can help you achieve more complex cases.
|
||||
### Authorizing scaffolder tasks
|
||||
|
||||
The scaffolder plugin also exposes permissions that can restrict access to tasks, task logs, task creation, and task cancellation. This can be useful if you want to control who has access to these areas of the scaffolder.
|
||||
|
||||
```ts title="packages/src/backend/plugins/permissions.ts"
|
||||
/* highlight-add-start */
|
||||
import {
|
||||
taskCancelPermission,
|
||||
taskCreatePermission,
|
||||
taskReadPermission,
|
||||
} from '@backstage/plugin-scaffolder-common/alpha';
|
||||
/* highlight-add-end */
|
||||
|
||||
class ExamplePermissionPolicy implements PermissionPolicy {
|
||||
async handle(
|
||||
request: PolicyQuery,
|
||||
user?: BackstageIdentityResponse,
|
||||
): Promise<PolicyDecision> {
|
||||
/* highlight-add-start */
|
||||
if (isPermission(request.permission, taskCreatePermission)) {
|
||||
if (user?.identity.userEntityRef === 'user:default/spiderman') {
|
||||
return {
|
||||
result: AuthorizeResult.ALLOW,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (isPermission(request.permission, taskCancelPermission)) {
|
||||
if (user?.identity.userEntityRef === 'user:default/spiderman') {
|
||||
return {
|
||||
result: AuthorizeResult.ALLOW,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (isPermission(request.permission, taskReadPermission)) {
|
||||
if (user?.identity.userEntityRef === 'user:default/spiderman') {
|
||||
return {
|
||||
result: AuthorizeResult.ALLOW,
|
||||
};
|
||||
}
|
||||
}
|
||||
/* highlight-add-end */
|
||||
|
||||
return {
|
||||
result: AuthorizeResult.DENY,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the provided example permission policy, we only grant the `spiderman` user permissions to perform/access the following actions/resources:
|
||||
|
||||
- Read all scaffolder tasks and their associated events/logs.
|
||||
- Cancel any ongoing scaffolder tasks.
|
||||
- Trigger software templates, which effectively creates new scaffolder tasks.
|
||||
|
||||
Any other user would be denied access to these actions/resources.
|
||||
|
||||
Although the rules exported by the scaffolder are simple, combining them can help you achieve more complex use cases.
|
||||
|
||||
### Authorizing in the New Backend System
|
||||
|
||||
@@ -171,6 +171,10 @@ const config: Config = {
|
||||
from: '/docs/getting-started/configuration',
|
||||
to: '/docs/getting-started/#next-steps',
|
||||
},
|
||||
{
|
||||
from: '/docs/features/software-templates/authorizing-parameters-steps-and-actions',
|
||||
to: '/docs/features/software-templates/authorizing-scaffolder-template-details',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
"features/software-templates/writing-tests-for-actions",
|
||||
"features/software-templates/writing-custom-field-extensions",
|
||||
"features/software-templates/writing-custom-step-layouts",
|
||||
"features/software-templates/authorizing-parameters-steps-and-actions",
|
||||
"features/software-templates/authorizing-scaffolder-template-details",
|
||||
"features/software-templates/migrating-to-rjsf-v5",
|
||||
"features/software-templates/migrating-from-v1beta2-to-v1beta3"
|
||||
]
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/pluralize": "^0.0.33"
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"swr": "^2.2.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
|
||||
|
||||
@@ -35,6 +35,7 @@ import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { permissionApiRef } from '@backstage/plugin-permission-react';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
const mockAuthorize = jest.fn();
|
||||
|
||||
@@ -546,7 +547,7 @@ describe('<AboutCard />', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders techdocs lin when 3rdparty', async () => {
|
||||
it('renders techdocs link when 3rdparty', async () => {
|
||||
const entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Component',
|
||||
@@ -774,7 +775,9 @@ describe('<AboutCard />', () => {
|
||||
namespace: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
mockAuthorize.mockImplementation(async () => ({
|
||||
result: AuthorizeResult.ALLOW,
|
||||
}));
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
@@ -794,7 +797,7 @@ describe('<AboutCard />', () => {
|
||||
),
|
||||
],
|
||||
[catalogApiRef, catalogApi],
|
||||
[permissionApiRef, {}],
|
||||
[permissionApiRef, mockPermissionApi],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={entity}>
|
||||
@@ -816,6 +819,58 @@ describe('<AboutCard />', () => {
|
||||
'/create/templates/default/create-react-app-template',
|
||||
);
|
||||
});
|
||||
it('renders disabled launch template button if user has insufficient permissions', async () => {
|
||||
const entity = {
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 'create-react-app-template',
|
||||
namespace: 'default',
|
||||
},
|
||||
};
|
||||
mockAuthorize.mockImplementation(async () => ({
|
||||
result: AuthorizeResult.DENY,
|
||||
}));
|
||||
await renderInTestApp(
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
scmIntegrationsApiRef,
|
||||
ScmIntegrationsApi.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
github: [
|
||||
{
|
||||
host: 'github.com',
|
||||
token: '...',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
[catalogApiRef, catalogApi],
|
||||
[permissionApiRef, mockPermissionApi],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={entity}>
|
||||
<AboutCard />
|
||||
</EntityProvider>
|
||||
</TestApiProvider>
|
||||
</SWRConfig>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
'/create/templates/:namespace/:templateName':
|
||||
createFromTemplateRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Launch Template')).toBeVisible();
|
||||
expect(screen.getByText('Launch Template').closest('a')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CompoundEntityRef,
|
||||
DEFAULT_NAMESPACE,
|
||||
stringifyEntityRef,
|
||||
parseEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
@@ -58,10 +59,11 @@ import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
|
||||
import DocsIcon from '@material-ui/icons/Description';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { parseEntityRef } from '@backstage/catalog-model';
|
||||
import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha';
|
||||
import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common/alpha';
|
||||
import { useSourceTemplateCompoundEntityRef } from './hooks';
|
||||
import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
|
||||
const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
|
||||
|
||||
@@ -115,6 +117,10 @@ export function AboutCard(props: AboutCardProps) {
|
||||
catalogEntityRefreshPermission,
|
||||
);
|
||||
|
||||
const { allowed: canCreateTemplateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
});
|
||||
|
||||
const entitySourceLocation = getEntitySourceLocation(
|
||||
entity,
|
||||
scmIntegrationsApi,
|
||||
@@ -172,7 +178,7 @@ export function AboutCard(props: AboutCardProps) {
|
||||
const launchTemplate: IconLinkVerticalProps = {
|
||||
label: 'Launch Template',
|
||||
icon: <Icon />,
|
||||
disabled: !templateRoute,
|
||||
disabled: !templateRoute || !canCreateTemplateTask,
|
||||
href:
|
||||
templateRoute &&
|
||||
templateRoute({
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SecureTemplateRenderer,
|
||||
} from '../../lib/templating/SecureTemplater';
|
||||
import {
|
||||
TaskRecovery,
|
||||
TaskSpec,
|
||||
TaskSpecV1beta3,
|
||||
TaskStep,
|
||||
@@ -52,7 +53,6 @@ import {
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { scaffolderActionRules } from '../../service/rules';
|
||||
import { actionExecutePermission } from '@backstage/plugin-scaffolder-common/alpha';
|
||||
import { TaskRecovery } from '@backstage/plugin-scaffolder-common';
|
||||
import { PermissionsService } from '@backstage/backend-plugin-api';
|
||||
import { loggerToWinstonLogger } from '@backstage/backend-common';
|
||||
import { BackstageLoggerTransport, WinstonLogger } from './logger';
|
||||
|
||||
@@ -235,6 +235,11 @@ describe('createRouter', () => {
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
]);
|
||||
jest.spyOn(permissionApi, 'authorize').mockImplementation(async () => [
|
||||
{
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -741,6 +746,11 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
]);
|
||||
jest.spyOn(permissionApi, 'authorize').mockImplementation(async () => [
|
||||
{
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -45,8 +45,12 @@ import {
|
||||
RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
|
||||
scaffolderActionPermissions,
|
||||
scaffolderTemplatePermissions,
|
||||
taskCancelPermission,
|
||||
taskCreatePermission,
|
||||
taskReadPermission,
|
||||
templateParameterReadPermission,
|
||||
templateStepReadPermission,
|
||||
scaffolderTaskPermissions,
|
||||
} from '@backstage/plugin-scaffolder-common/alpha';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
@@ -54,6 +58,7 @@ import { validate } from 'jsonschema';
|
||||
import { Logger } from 'winston';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
TemplateAction,
|
||||
TaskBroker,
|
||||
TemplateFilter,
|
||||
TemplateGlobal,
|
||||
@@ -67,7 +72,6 @@ import {
|
||||
import { createDryRunner } from '../scaffolder/dryrun';
|
||||
import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker';
|
||||
import { findTemplate, getEntityBaseUrl, getWorkingDirectory } from './helpers';
|
||||
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import { PermissionRuleParams } from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
createConditionAuthorizer,
|
||||
@@ -89,6 +93,7 @@ import {
|
||||
IdentityApiGetIdentityRequest,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { InternalTaskSecrets } from '../scaffolder/tasks/types';
|
||||
import { checkPermission } from '../util/checkPermissions';
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -407,6 +412,7 @@ export async function createRouter(
|
||||
rules: actionRules,
|
||||
},
|
||||
],
|
||||
permissions: scaffolderTaskPermissions,
|
||||
});
|
||||
|
||||
router.use(permissionIntegrationRouter);
|
||||
@@ -463,6 +469,12 @@ export async function createRouter(
|
||||
});
|
||||
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
await checkPermission({
|
||||
credentials,
|
||||
permissions: [taskCreatePermission],
|
||||
permissionService: permissions,
|
||||
});
|
||||
|
||||
const { token } = await auth.getPluginRequestToken({
|
||||
onBehalfOf: credentials,
|
||||
targetPluginId: 'catalog',
|
||||
@@ -539,8 +551,14 @@ export async function createRouter(
|
||||
res.status(201).json({ id: result.taskId });
|
||||
})
|
||||
.get('/v2/tasks', async (req, res) => {
|
||||
const [userEntityRef] = [req.query.createdBy].flat();
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
await checkPermission({
|
||||
credentials,
|
||||
permissions: [taskReadPermission],
|
||||
permissionService: permissions,
|
||||
});
|
||||
|
||||
const [userEntityRef] = [req.query.createdBy].flat();
|
||||
if (
|
||||
typeof userEntityRef !== 'string' &&
|
||||
typeof userEntityRef !== 'undefined'
|
||||
@@ -561,6 +579,13 @@ export async function createRouter(
|
||||
res.status(200).json(tasks);
|
||||
})
|
||||
.get('/v2/tasks/:taskId', async (req, res) => {
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
await checkPermission({
|
||||
credentials,
|
||||
permissions: [taskReadPermission],
|
||||
permissionService: permissions,
|
||||
});
|
||||
|
||||
const { taskId } = req.params;
|
||||
const task = await taskBroker.get(taskId);
|
||||
if (!task) {
|
||||
@@ -571,11 +596,26 @@ export async function createRouter(
|
||||
res.status(200).json(task);
|
||||
})
|
||||
.post('/v2/tasks/:taskId/cancel', async (req, res) => {
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
// Requires both read and cancel permissions
|
||||
await checkPermission({
|
||||
credentials,
|
||||
permissions: [taskCancelPermission, taskReadPermission],
|
||||
permissionService: permissions,
|
||||
});
|
||||
|
||||
const { taskId } = req.params;
|
||||
await taskBroker.cancel?.(taskId);
|
||||
res.status(200).json({ status: 'cancelled' });
|
||||
})
|
||||
.get('/v2/tasks/:taskId/eventstream', async (req, res) => {
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
await checkPermission({
|
||||
credentials,
|
||||
permissions: [taskReadPermission],
|
||||
permissionService: permissions,
|
||||
});
|
||||
|
||||
const { taskId } = req.params;
|
||||
const after =
|
||||
req.query.after !== undefined ? Number(req.query.after) : undefined;
|
||||
@@ -624,6 +664,13 @@ export async function createRouter(
|
||||
});
|
||||
})
|
||||
.get('/v2/tasks/:taskId/events', async (req, res) => {
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
await checkPermission({
|
||||
credentials,
|
||||
permissions: [taskReadPermission],
|
||||
permissionService: permissions,
|
||||
});
|
||||
|
||||
const { taskId } = req.params;
|
||||
const after = Number(req.query.after) || undefined;
|
||||
|
||||
@@ -654,6 +701,13 @@ export async function createRouter(
|
||||
});
|
||||
})
|
||||
.post('/v2/dry-run', async (req, res) => {
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
await checkPermission({
|
||||
credentials,
|
||||
permissions: [taskCreatePermission],
|
||||
permissionService: permissions,
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
template: z.unknown(),
|
||||
values: z.record(z.unknown()),
|
||||
@@ -671,8 +725,6 @@ export async function createRouter(
|
||||
throw new InputError('Input template is not a template');
|
||||
}
|
||||
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
|
||||
const { token } = await auth.getPluginRequestToken({
|
||||
onBehalfOf: credentials,
|
||||
targetPluginId: 'catalog',
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 {
|
||||
BackstageCredentials,
|
||||
PermissionsService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
BasicPermission,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
|
||||
export type checkPermissionOptions = {
|
||||
credentials: BackstageCredentials;
|
||||
permissions: BasicPermission[];
|
||||
permissionService?: PermissionsService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Does a basic check on permissions. Throws 403 error if any permission responds with AuthorizeResult.DENY
|
||||
* @public
|
||||
*/
|
||||
export async function checkPermission(options: checkPermissionOptions) {
|
||||
const { permissions, permissionService, credentials } = options;
|
||||
if (permissionService) {
|
||||
const permissionRequest = permissions.map(permission => ({
|
||||
permission,
|
||||
}));
|
||||
const authorizationResponses = await permissionService.authorize(
|
||||
permissionRequest,
|
||||
{ credentials: credentials },
|
||||
);
|
||||
|
||||
for (const response of authorizationResponses) {
|
||||
if (response.result === AuthorizeResult.DENY) {
|
||||
throw new NotAllowedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
> 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';
|
||||
|
||||
// @alpha
|
||||
@@ -19,13 +20,26 @@ export const scaffolderActionPermissions: ResourcePermission<'scaffolder-action'
|
||||
|
||||
// @alpha
|
||||
export const scaffolderPermissions: (
|
||||
| BasicPermission
|
||||
| ResourcePermission<'scaffolder-action'>
|
||||
| ResourcePermission<'scaffolder-template'>
|
||||
)[];
|
||||
|
||||
// @alpha
|
||||
export const scaffolderTaskPermissions: BasicPermission[];
|
||||
|
||||
// @alpha
|
||||
export const scaffolderTemplatePermissions: ResourcePermission<'scaffolder-template'>[];
|
||||
|
||||
// @alpha
|
||||
export const taskCancelPermission: BasicPermission;
|
||||
|
||||
// @alpha
|
||||
export const taskCreatePermission: BasicPermission;
|
||||
|
||||
// @alpha
|
||||
export const taskReadPermission: BasicPermission;
|
||||
|
||||
// @alpha
|
||||
export const templateParameterReadPermission: ResourcePermission<'scaffolder-template'>;
|
||||
|
||||
|
||||
@@ -79,14 +79,39 @@ export const templateStepReadPermission = createPermission({
|
||||
});
|
||||
|
||||
/**
|
||||
* List of all the scaffolder permissions
|
||||
* This permission is used to authorize actions that involve reading one or more tasks in the scaffolder,
|
||||
* and reading logs of tasks
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const scaffolderPermissions = [
|
||||
actionExecutePermission,
|
||||
templateParameterReadPermission,
|
||||
templateStepReadPermission,
|
||||
];
|
||||
export const taskReadPermission = createPermission({
|
||||
name: 'scaffolder.task.read',
|
||||
attributes: {
|
||||
action: 'read',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* This permission is used to authorize actions that involve the creation of tasks in the scaffolder.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const taskCreatePermission = createPermission({
|
||||
name: 'scaffolder.task.create',
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* This permission is used to authorize actions that involve the cancellation of tasks in the scaffolder.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const taskCancelPermission = createPermission({
|
||||
name: 'scaffolder.task.cancel',
|
||||
attributes: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* List of the scaffolder permissions that are associated with template steps and parameters.
|
||||
@@ -102,3 +127,23 @@ export const scaffolderTemplatePermissions = [
|
||||
* @alpha
|
||||
*/
|
||||
export const scaffolderActionPermissions = [actionExecutePermission];
|
||||
|
||||
/**
|
||||
* List of the scaffolder permissions that are associated with scaffolder tasks.
|
||||
* @alpha
|
||||
*/
|
||||
export const scaffolderTaskPermissions = [
|
||||
taskCancelPermission,
|
||||
taskCreatePermission,
|
||||
taskReadPermission,
|
||||
];
|
||||
|
||||
/**
|
||||
* List of all the scaffolder permissions
|
||||
* @alpha
|
||||
*/
|
||||
export const scaffolderPermissions = [
|
||||
...scaffolderTemplatePermissions,
|
||||
...scaffolderActionPermissions,
|
||||
...scaffolderTaskPermissions,
|
||||
];
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-catalog-react": "workspace:^",
|
||||
"@backstage/plugin-permission-react": "workspace:^",
|
||||
"@backstage/plugin-scaffolder-common": "workspace:^",
|
||||
"@backstage/theme": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
@@ -87,13 +88,15 @@
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/plugin-catalog": "workspace:^",
|
||||
"@backstage/plugin-catalog-common": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/humanize-duration": "^3.18.1",
|
||||
"@types/luxon": "^3.0.0"
|
||||
"@types/luxon": "^3.0.0",
|
||||
"swr": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
|
||||
|
||||
@@ -14,7 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ComponentType, ElementType, FormEvent, ReactNode, Ref } from 'react';
|
||||
import {
|
||||
ComponentType,
|
||||
ElementType,
|
||||
FormEvent,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
Ref,
|
||||
} from 'react';
|
||||
import {
|
||||
ErrorSchema,
|
||||
FormContextType,
|
||||
@@ -32,7 +39,6 @@ import {
|
||||
Experimental_DefaultFormStateBehavior,
|
||||
ErrorTransformer,
|
||||
} from '@rjsf/utils';
|
||||
import { HTMLAttributes } from 'react';
|
||||
import Form, { IChangeEvent } from '@rjsf/core';
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
starredEntitiesApiRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
MockPermissionApi,
|
||||
MockStorageApi,
|
||||
renderInTestApp,
|
||||
TestApiProvider,
|
||||
@@ -28,6 +29,9 @@ import React from 'react';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { permissionApiRef } from '@backstage/plugin-permission-react';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
describe('TemplateCard', () => {
|
||||
it('should render the card title', async () => {
|
||||
@@ -50,6 +54,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -79,6 +84,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -110,6 +116,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -139,6 +146,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -174,6 +182,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -213,6 +222,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -257,6 +267,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} additionalLinks={[]} />
|
||||
@@ -305,6 +316,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} additionalLinks={[]} />
|
||||
@@ -347,6 +359,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -386,6 +399,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} onSelected={mockOnSelected} />
|
||||
@@ -403,4 +417,44 @@ describe('TemplateCard', () => {
|
||||
|
||||
expect(mockOnSelected).toHaveBeenCalledWith(mockTemplate);
|
||||
});
|
||||
it('should not render the choose button when user has insufficient permissions', async () => {
|
||||
const mockTemplate: TemplateEntityV1beta3 = {
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: { name: 'bob', tags: ['cpp', 'react'] },
|
||||
spec: {
|
||||
steps: [],
|
||||
type: 'service',
|
||||
},
|
||||
};
|
||||
const mockOnSelected = jest.fn();
|
||||
const mockAuthorize = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ result: AuthorizeResult.DENY }));
|
||||
// SWR used by the usePermission hook needs cache to be reset for each test
|
||||
const { queryByText } = await renderInTestApp(
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
starredEntitiesApiRef,
|
||||
new DefaultStarredEntitiesApi({
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi(mockAuthorize)],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} onSelected={mockOnSelected} />
|
||||
</TestApiProvider>
|
||||
</SWRConfig>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:kind/:namespace/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByText('Choose')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,8 @@ import LanguageIcon from '@material-ui/icons/Language';
|
||||
import React, { useCallback } from 'react';
|
||||
import { CardHeader } from './CardHeader';
|
||||
import { CardLink } from './CardLink';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha';
|
||||
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
box: {
|
||||
@@ -108,6 +110,9 @@ export const TemplateCard = (props: TemplateCardProps) => {
|
||||
!!props.additionalLinks?.length || !!template.metadata.links?.length;
|
||||
const displayDefaultDivider = !hasTags && !hasLinks;
|
||||
|
||||
const { allowed: canCreateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
});
|
||||
const handleChoose = useCallback(() => {
|
||||
analytics.captureEvent('click', `Template has been opened`);
|
||||
onSelected?.(template);
|
||||
@@ -196,14 +201,16 @@ export const TemplateCard = (props: TemplateCardProps) => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleChoose}
|
||||
>
|
||||
Choose
|
||||
</Button>
|
||||
{canCreateTask ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleChoose}
|
||||
>
|
||||
Choose
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardActions>
|
||||
</Card>
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
MockStarredEntitiesApi,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { scaffolderApiRef, ScaffolderClient } from '../src';
|
||||
import { ScaffolderClient } from '../src';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import { ScaffolderPage } from '../src/plugin';
|
||||
import {
|
||||
discoveryApiRef,
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/dev-utils": "workspace:^",
|
||||
"@backstage/plugin-catalog": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
@@ -105,7 +106,8 @@
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/humanize-duration": "^3.18.1",
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"msw": "^1.0.0"
|
||||
"msw": "^1.0.0",
|
||||
"swr": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
|
||||
|
||||
@@ -43,7 +43,8 @@ import { useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
CodeSnippet,
|
||||
Content,
|
||||
ErrorPage,
|
||||
EmptyState,
|
||||
ErrorPanel,
|
||||
Header,
|
||||
MarkdownContent,
|
||||
Page,
|
||||
@@ -112,19 +113,9 @@ const ExamplesTable = (props: { examples: ActionExample[] }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const ActionsPage = () => {
|
||||
const ActionPageContent = () => {
|
||||
const api = useApi(scaffolderApiRef);
|
||||
const navigate = useNavigate();
|
||||
const editorLink = useRouteRef(editRouteRef);
|
||||
const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
|
||||
const createLink = useRouteRef(rootRouteRef);
|
||||
|
||||
const scaffolderPageContextMenuProps = {
|
||||
onEditorClicked: () => navigate(editorLink()),
|
||||
onActionsClicked: undefined,
|
||||
onTasksClicked: () => navigate(tasksLink()),
|
||||
onCreateClicked: () => navigate(createLink()),
|
||||
};
|
||||
const classes = useStyles();
|
||||
const { loading, value, error } = useAsync(async () => {
|
||||
return api.listActions();
|
||||
@@ -137,11 +128,14 @@ export const ActionsPage = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
statusMessage="Failed to load installed actions"
|
||||
status="500"
|
||||
stack={error.stack}
|
||||
/>
|
||||
<>
|
||||
<ErrorPanel error={error} />
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="There are no actions installed or there was an issue communicating with backend."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,7 +276,7 @@ export const ActionsPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const items = value?.map(action => {
|
||||
return value?.map(action => {
|
||||
if (action.id.startsWith('legacy:')) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -336,6 +330,19 @@ export const ActionsPage = () => {
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
};
|
||||
export const ActionsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const editorLink = useRouteRef(editRouteRef);
|
||||
const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
|
||||
const createLink = useRouteRef(rootRouteRef);
|
||||
|
||||
const scaffolderPageContextMenuProps = {
|
||||
onEditorClicked: () => navigate(editorLink()),
|
||||
onActionsClicked: undefined,
|
||||
onTasksClicked: () => navigate(tasksLink()),
|
||||
onCreateClicked: () => navigate(createLink()),
|
||||
};
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
@@ -346,7 +353,9 @@ export const ActionsPage = () => {
|
||||
>
|
||||
<ScaffolderPageContextMenu {...scaffolderPageContextMenuProps} />
|
||||
</Header>
|
||||
<Content>{items}</Content>
|
||||
<Content>
|
||||
<ActionPageContent />
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -77,7 +77,7 @@ const ListTaskPageContent = (props: MyTaskPageProps) => {
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="There is no Tasks or there was an issue communicating with backend."
|
||||
description="There are no tasks or there was an issue communicating with backend."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,12 @@ import MoreVert from '@material-ui/icons/MoreVert';
|
||||
import React, { useState } from 'react';
|
||||
import { useAnalytics, useApi } from '@backstage/core-plugin-api';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
taskCancelPermission,
|
||||
taskReadPermission,
|
||||
taskCreatePermission,
|
||||
} from '@backstage/plugin-scaffolder-common/alpha';
|
||||
|
||||
type ContextMenuProps = {
|
||||
cancelEnabled?: boolean;
|
||||
@@ -71,6 +77,21 @@ export const ContextMenu = (props: ContextMenuProps) => {
|
||||
}
|
||||
});
|
||||
|
||||
const { allowed: canCancelTask } = usePermission({
|
||||
permission: taskCancelPermission,
|
||||
});
|
||||
|
||||
const { allowed: canReadTask } = usePermission({
|
||||
permission: taskReadPermission,
|
||||
});
|
||||
|
||||
const { allowed: canCreateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
});
|
||||
|
||||
// Start Over endpoint requires user to have both read (to grab parameters) and create (to create new task) permissions
|
||||
const canStartOver = canReadTask && canCreateTask;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
@@ -107,7 +128,11 @@ export const ContextMenu = (props: ContextMenuProps) => {
|
||||
primary={buttonBarVisible ? 'Hide Button Bar' : 'Show Button Bar'}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onStartOver}>
|
||||
<MenuItem
|
||||
onClick={onStartOver}
|
||||
disabled={cancelEnabled || !canStartOver}
|
||||
data-testid="start-over-task"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Retry fontSize="small" />
|
||||
</ListItemIcon>
|
||||
@@ -115,7 +140,11 @@ export const ContextMenu = (props: ContextMenuProps) => {
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={cancel}
|
||||
disabled={!cancelEnabled || cancelStatus !== 'not-executed'}
|
||||
disabled={
|
||||
!cancelEnabled ||
|
||||
cancelStatus !== 'not-executed' ||
|
||||
!canCancelTask
|
||||
}
|
||||
data-testid="cancel-task"
|
||||
>
|
||||
<ListItemIcon>
|
||||
|
||||
@@ -16,10 +16,20 @@
|
||||
|
||||
import { OngoingTask } from './OngoingTask';
|
||||
import React from 'react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import {
|
||||
renderInTestApp,
|
||||
TestApiProvider,
|
||||
MockPermissionApi,
|
||||
} from '@backstage/test-utils';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import { act, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import {
|
||||
PermissionApi,
|
||||
permissionApiRef,
|
||||
} from '@backstage/plugin-permission-react';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
@@ -49,18 +59,29 @@ describe('OngoingTask', () => {
|
||||
getTask: jest.fn().mockImplementation(async () => {}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should trigger cancel api on "Cancel" click in context menu', async () => {
|
||||
const cancelOptionLabel = 'Cancel';
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<OngoingTask />
|
||||
</TestApiProvider>,
|
||||
const render = (permissionApi?: PermissionApi) => {
|
||||
// SWR used by the usePermission hook needs cache to be reset for each test
|
||||
return renderInTestApp(
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scaffolderApiRef, mockScaffolderApi],
|
||||
[permissionApiRef, permissionApi || new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<OngoingTask />
|
||||
</TestApiProvider>
|
||||
</SWRConfig>,
|
||||
{ mountedRoutes: { '/': rootRouteRef } },
|
||||
);
|
||||
};
|
||||
it('should trigger cancel api on "Cancel" click in context menu', async () => {
|
||||
const rendered = await render();
|
||||
const cancelOptionLabel = 'Cancel';
|
||||
const { getByTestId } = rendered;
|
||||
|
||||
await act(async () => {
|
||||
@@ -84,13 +105,9 @@ describe('OngoingTask', () => {
|
||||
});
|
||||
|
||||
it('should trigger cancel api on "Cancel" button click', async () => {
|
||||
const rendered = await render();
|
||||
const cancelOptionLabel = 'Cancel';
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<OngoingTask />
|
||||
</TestApiProvider>,
|
||||
{ mountedRoutes: { '/': rootRouteRef } },
|
||||
);
|
||||
|
||||
const { getByTestId } = rendered;
|
||||
|
||||
await act(async () => {
|
||||
@@ -114,22 +131,12 @@ describe('OngoingTask', () => {
|
||||
});
|
||||
|
||||
it('should initially do not display logs', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<OngoingTask />
|
||||
</TestApiProvider>,
|
||||
{ mountedRoutes: { '/': rootRouteRef } },
|
||||
);
|
||||
const rendered = await render();
|
||||
await expect(rendered.findByText('Show Logs')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle logs visibility', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<OngoingTask />
|
||||
</TestApiProvider>,
|
||||
{ mountedRoutes: { '/': rootRouteRef } },
|
||||
);
|
||||
const rendered = await render();
|
||||
await act(async () => {
|
||||
const element = await rendered.findByText('Show Logs');
|
||||
fireEvent.click(element);
|
||||
@@ -137,4 +144,22 @@ describe('OngoingTask', () => {
|
||||
|
||||
await expect(rendered.findByText('Hide Logs')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have cancel and start over buttons be disabled without the proper permissions', async () => {
|
||||
const mockAuthorize = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ result: AuthorizeResult.DENY }));
|
||||
const permissionApi: PermissionApi = { authorize: mockAuthorize };
|
||||
const rendered = await render(permissionApi);
|
||||
|
||||
const { getByTestId } = rendered;
|
||||
expect(getByTestId('cancel-button')).toHaveClass('Mui-disabled');
|
||||
expect(getByTestId('start-over-button')).toHaveClass('Mui-disabled');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('menu-button'));
|
||||
});
|
||||
expect(getByTestId('cancel-task')).toHaveClass('Mui-disabled');
|
||||
expect(getByTestId('start-over-task')).toHaveClass('Mui-disabled');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
TaskSteps,
|
||||
} from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { useAsync } from '@react-hookz/web';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
taskCancelPermission,
|
||||
taskReadPermission,
|
||||
taskCreatePermission,
|
||||
} from '@backstage/plugin-scaffolder-common/alpha';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
@@ -82,6 +88,22 @@ export const OngoingTask = (props: {
|
||||
const [logsVisible, setLogVisibleState] = useState(false);
|
||||
const [buttonBarVisible, setButtonBarVisibleState] = useState(true);
|
||||
|
||||
// Used dummy string value for `resourceRef` since `allowed` field will always return `false` if `resourceRef` is `undefined`
|
||||
const { allowed: canCancelTask } = usePermission({
|
||||
permission: taskCancelPermission,
|
||||
});
|
||||
|
||||
const { allowed: canReadTask } = usePermission({
|
||||
permission: taskReadPermission,
|
||||
});
|
||||
|
||||
const { allowed: canCreateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
});
|
||||
|
||||
// Start Over endpoint requires user to have both read (to grab parameters) and create (to create new task) permissions
|
||||
const canStartOver = canReadTask && canCreateTask;
|
||||
|
||||
useEffect(() => {
|
||||
if (taskStream.error) {
|
||||
setLogVisibleState(true);
|
||||
@@ -197,7 +219,11 @@ export const OngoingTask = (props: {
|
||||
<div className={classes.buttonBar}>
|
||||
<Button
|
||||
className={classes.cancelButton}
|
||||
disabled={!cancelEnabled || cancelStatus !== 'not-executed'}
|
||||
disabled={
|
||||
!cancelEnabled ||
|
||||
cancelStatus !== 'not-executed' ||
|
||||
!canCancelTask
|
||||
}
|
||||
onClick={triggerCancel}
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
@@ -214,8 +240,9 @@ export const OngoingTask = (props: {
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={cancelEnabled}
|
||||
disabled={cancelEnabled || !canStartOver}
|
||||
onClick={startOver}
|
||||
data-testid="start-over-button"
|
||||
>
|
||||
Start Over
|
||||
</Button>
|
||||
|
||||
@@ -5668,6 +5668,7 @@ __metadata:
|
||||
lodash: ^4.17.21
|
||||
pluralize: ^8.0.0
|
||||
react-use: ^17.2.4
|
||||
swr: ^2.2.5
|
||||
zen-observable: ^0.10.0
|
||||
peerDependencies:
|
||||
react: ^16.13.1 || ^17.0.0 || ^18.0.0
|
||||
@@ -6887,6 +6888,8 @@ __metadata:
|
||||
"@backstage/plugin-catalog": "workspace:^"
|
||||
"@backstage/plugin-catalog-common": "workspace:^"
|
||||
"@backstage/plugin-catalog-react": "workspace:^"
|
||||
"@backstage/plugin-permission-common": "workspace:^"
|
||||
"@backstage/plugin-permission-react": "workspace:^"
|
||||
"@backstage/plugin-scaffolder-common": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/theme": "workspace:^"
|
||||
@@ -6917,6 +6920,7 @@ __metadata:
|
||||
luxon: ^3.0.0
|
||||
qs: ^6.9.4
|
||||
react-use: ^17.2.4
|
||||
swr: ^2.0.0
|
||||
use-immer: ^0.9.0
|
||||
zen-observable: ^0.10.0
|
||||
zod: ^3.22.4
|
||||
@@ -6947,6 +6951,7 @@ __metadata:
|
||||
"@backstage/plugin-catalog": "workspace:^"
|
||||
"@backstage/plugin-catalog-common": "workspace:^"
|
||||
"@backstage/plugin-catalog-react": "workspace:^"
|
||||
"@backstage/plugin-permission-common": "workspace:^"
|
||||
"@backstage/plugin-permission-react": "workspace:^"
|
||||
"@backstage/plugin-scaffolder-common": "workspace:^"
|
||||
"@backstage/plugin-scaffolder-react": "workspace:^"
|
||||
@@ -6983,6 +6988,7 @@ __metadata:
|
||||
msw: ^1.0.0
|
||||
qs: ^6.9.4
|
||||
react-use: ^17.2.4
|
||||
swr: ^2.0.0
|
||||
yaml: ^2.0.0
|
||||
zen-observable: ^0.10.0
|
||||
zod: ^3.22.4
|
||||
@@ -40994,7 +41000,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"swr@npm:^2.0.0":
|
||||
"swr@npm:^2.0.0, swr@npm:^2.2.5":
|
||||
version: 2.2.5
|
||||
resolution: "swr@npm:2.2.5"
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user