added files related to db queries, api-reports and changeset

Signed-off-by: Kashish Mittal <kmittal@redhat.com>
This commit is contained in:
Kashish Mittal
2025-03-12 19:28:29 -04:00
parent 152ae1e3c5
commit c1ce3164ae
17 changed files with 493 additions and 19 deletions
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/plugin-scaffolder-backend': minor
'@backstage/plugin-scaffolder-common': minor
'@backstage/plugin-scaffolder-react': minor
'@backstage/plugin-scaffolder-node': minor
'@backstage/plugin-scaffolder': minor
---
BREAKING : Added two new scaffolder rules for `scaffolder.task.read` and `scaffolder.task.cancel` to allow for conditional permission policies such as restricting access to tasks and task events based on creators (`hasCreatedBy`) and granting template owners visibility into all runs of their templates (`hasTemplateEntityRefs`).
BREAKING: Removed requirement to have both `scaffolder.task.read` and `scaffolder.task.cancel` permissions to cancel tasks.
@@ -10,6 +10,8 @@ import { PermissionCondition } from '@backstage/plugin-permission-common';
import { PermissionCriteria } from '@backstage/plugin-permission-common';
import { PermissionRule } from '@backstage/plugin-permission-node';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { SerializedTask } from '@backstage/plugin-scaffolder-node';
import { TaskFilter } from '@backstage/plugin-scaffolder-node';
import { TemplateEntityStepV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplateParametersV1beta3 } from '@backstage/plugin-scaffolder-common';
@@ -19,6 +21,12 @@ export const createScaffolderActionConditionalDecision: (
conditions: PermissionCriteria<PermissionCondition<'scaffolder-action'>>,
) => ConditionalPolicyDecision;
// @alpha (undocumented)
export const createScaffolderTaskConditionalDecision: (
permission: ResourcePermission<'scaffolder-task'>,
conditions: PermissionCriteria<PermissionCondition<'scaffolder-task'>>,
) => ConditionalPolicyDecision;
// @alpha
export const createScaffolderTemplateConditionalDecision: (
permission: ResourcePermission<'scaffolder-template'>,
@@ -76,6 +84,32 @@ export const scaffolderActionConditions: Conditions<{
>;
}>;
// @alpha
export const scaffolderTaskConditions: Conditions<{
hasCreatedBy: PermissionRule<
SerializedTask,
{
property: TaskFilter['property'];
values: any;
},
'scaffolder-task',
{
createdBy: string[];
}
>;
hasTemplateEntityRefs: PermissionRule<
SerializedTask,
{
property: TaskFilter['property'];
values: any;
},
'scaffolder-task',
{
templateEntityRefs: string[];
}
>;
}>;
// @alpha
export const scaffolderTemplateConditions: Conditions<{
hasTag: PermissionRule<
+3
View File
@@ -17,6 +17,7 @@ import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
import { Knex } from 'knex';
import { LoggerService } from '@backstage/backend-plugin-api';
import { PermissionCriteria } from '@backstage/plugin-permission-common';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { PermissionRule } from '@backstage/plugin-permission-node';
import { PermissionRuleParams } from '@backstage/plugin-permission-common';
@@ -28,6 +29,7 @@ import { SerializedTaskEvent } from '@backstage/plugin-scaffolder-node';
import { TaskBroker } from '@backstage/plugin-scaffolder-node';
import { TaskCompletionState } from '@backstage/plugin-scaffolder-node';
import { TaskContext } from '@backstage/plugin-scaffolder-node';
import { TaskFilters } from '@backstage/plugin-scaffolder-node';
import { TaskRecovery } from '@backstage/plugin-scaffolder-common';
import { TaskSecrets } from '@backstage/plugin-scaffolder-node';
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
@@ -351,6 +353,7 @@ export class DatabaseTaskStore implements TaskStore {
order: 'asc' | 'desc';
field: string;
}[];
permissionFilters?: PermissionCriteria<TaskFilters>;
}): Promise<{
tasks: SerializedTask[];
totalTasks?: number;
@@ -22,6 +22,8 @@ import { ConflictError } from '@backstage/errors';
import { createMockDirectory } from '@backstage/backend-test-utils';
import fs from 'fs-extra';
import { EventsService } from '@backstage/plugin-events-node';
import { PermissionCriteria } from '@backstage/plugin-permission-common';
import { TaskFilters } from '@backstage/plugin-scaffolder-node';
const createStore = async (events?: EventsService) => {
const manager = DatabaseManager.fromConfig(
@@ -231,6 +233,73 @@ describe('DatabaseTaskStore', () => {
expect(tasks[0].id).toBeDefined();
});
it('should filter tasks based on permissionFilters', async () => {
const { store } = await createStore();
await store.createTask({
spec: {
templateInfo: { entityRef: 'template:default/three' },
} as TaskSpec,
createdBy: 'user:default/one',
});
await store.createTask({
spec: {
templateInfo: { entityRef: 'template:default/four' },
} as TaskSpec,
createdBy: 'user:default/two',
});
await store.createTask({
spec: {
templateInfo: { entityRef: 'template:default/one' },
} as TaskSpec,
createdBy: 'user:default/three',
});
await store.createTask({
spec: {
templateInfo: { entityRef: 'template:default/two' },
} as TaskSpec,
createdBy: 'user:default/three',
});
await store.createTask({
spec: {
templateInfo: { entityRef: 'template:default/three' },
} as TaskSpec,
createdBy: 'user:default/three',
});
const permissionFilters: PermissionCriteria<TaskFilters> = {
anyOf: [
{
property: 'createdBy',
values: ['user:default/one', 'user:default/two'],
},
{
property: 'templateEntityRefs',
values: ['template:default/one', 'template:default/two'],
},
],
};
const { tasks, totalTasks } = await store.list({
permissionFilters: permissionFilters,
});
expect(totalTasks).toBe(4);
expect(tasks).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
createdBy: 'user:default/three',
spec: { templateInfo: { entityRef: 'template:default/three' } },
}),
]),
);
});
it('should sent an event to start cancelling the task', async () => {
const { store } = await createStore(eventsService);
@@ -35,6 +35,7 @@ import {
SerializedTask,
SerializedTaskEvent,
TaskEventType,
TaskFilter,
TaskSecrets,
TaskStatus,
} from '@backstage/plugin-scaffolder-node';
@@ -48,6 +49,14 @@ import {
} from '@backstage/plugin-scaffolder-node/alpha';
import { flattenParams } from '../../service/helpers';
import { EventsService } from '@backstage/plugin-events-node';
import { PermissionCriteria } from '@backstage/plugin-permission-common';
import {
isAndCriteria,
isNotCriteria,
isOrCriteria,
} from '@backstage/plugin-permission-node';
import { TaskFilters } from '@backstage/plugin-scaffolder-node';
import { compact } from 'lodash';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-scaffolder-backend',
@@ -195,6 +204,63 @@ export class DatabaseTaskStore implements TaskStore {
}
}
private isTaskFilter(filter: any): filter is TaskFilter {
return filter.hasOwnProperty('property');
}
private parseFilter(
filter: PermissionCriteria<TaskFilters>,
query: Knex.QueryBuilder,
db: Knex,
negate: boolean = false,
): Knex.QueryBuilder {
// handle not criteria
if (isNotCriteria(filter)) {
return this.parseFilter(filter.not, query, db, !negate);
}
if (this.isTaskFilter(filter)) {
const values: string[] = compact(filter.values) ?? [];
if (filter.property === 'createdBy') {
query.whereIn('created_by', [...new Set(values)]);
}
if (filter.property === 'templateEntityRefs' && values.length > 0) {
const dbClient = this.db.client.config.client;
const placeholders = values.map(() => '?').join(', ');
if (dbClient === 'pg') {
query.whereRaw(
`spec::jsonb->'templateInfo'->>'entityRef' IN (${placeholders})`,
values,
);
} else if (dbClient === 'better-sqlite3') {
query.whereRaw(
`json_extract(spec, '$.templateInfo.entityRef') IN (${placeholders})`,
values,
);
}
}
return query;
}
return query[negate ? 'andWhereNot' : 'andWhere'](subQuery => {
if (isOrCriteria(filter)) {
for (const subFilter of filter.anyOf ?? []) {
subQuery.orWhere(subQueryInner =>
this.parseFilter(subFilter, subQueryInner, db, false),
);
}
} else if (isAndCriteria(filter)) {
for (const subFilter of filter.allOf ?? []) {
subQuery.andWhere(subQueryInner =>
this.parseFilter(subFilter, subQueryInner, db, false),
);
}
}
});
}
async list(options: {
createdBy?: string;
status?: TaskStatus;
@@ -207,16 +273,31 @@ export class DatabaseTaskStore implements TaskStore {
offset?: number;
};
order?: { order: 'asc' | 'desc'; field: string }[];
permissionFilters?: PermissionCriteria<TaskFilters>;
}): Promise<{ tasks: SerializedTask[]; totalTasks?: number }> {
const { createdBy, status, pagination, order, filters } = options ?? {};
const { createdBy, status, pagination, order, filters, permissionFilters } =
options ?? {};
const queryBuilder = this.db<RawDbTaskRow & { count: number }>('tasks');
if (createdBy || filters?.createdBy) {
const arr: string[] = flattenParams<string>(
createdBy,
filters?.createdBy,
);
queryBuilder.whereIn('created_by', [...new Set(arr)]);
const createdByValues = flattenParams<string>(
createdBy,
filters?.createdBy,
);
const combinedPermissionFilters:
| PermissionCriteria<TaskFilters>
| undefined =
createdByValues.length > 0
? {
allOf: [
{ property: 'createdBy', values: createdByValues },
...(permissionFilters ? [permissionFilters] : []),
],
}
: permissionFilters;
if (combinedPermissionFilters) {
this.parseFilter(combinedPermissionFilters, queryBuilder, this.db);
}
if (status || filters?.status) {
@@ -28,7 +28,6 @@ import { PermissionRuleParams } from '@backstage/plugin-permission-common';
import {
SerializedTask,
TaskFilter,
TaskFilters,
} from '@backstage/plugin-scaffolder-node';
/**
@@ -52,7 +51,7 @@ export type TemplatePermissionRuleInput<
TParams
>;
export function isTemplatePermissionRuleInput(
permissionRule: TemplatePermissionRuleInput | ActionPermissionRuleInput,
permissionRule: ScaffolderPermissionRuleInput,
): permissionRule is TemplatePermissionRuleInput {
return permissionRule.resourceType === RESOURCE_TYPE_SCAFFOLDER_TEMPLATE;
}
@@ -70,7 +69,7 @@ export type ActionPermissionRuleInput<
TParams
>;
export function isActionPermissionRuleInput(
permissionRule: TemplatePermissionRuleInput | ActionPermissionRuleInput,
permissionRule: ScaffolderPermissionRuleInput,
): permissionRule is ActionPermissionRuleInput {
return permissionRule.resourceType === RESOURCE_TYPE_SCAFFOLDER_ACTION;
}
@@ -1420,3 +1420,4 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
});
});
});
@@ -42,7 +42,6 @@ import { EventsService } from '@backstage/plugin-events-node';
import {
createConditionAuthorizer,
createPermissionIntegrationRouter,
PermissionRule,
createConditionTransformer,
ConditionTransformer,
} from '@backstage/plugin-permission-node';
@@ -132,6 +131,10 @@ import {
scaffolderTaskRules,
} from './rules';
import {
TaskFilters,
} from '@backstage/plugin-scaffolder-node';
/**
* RouterOptions
*/
@@ -158,10 +161,8 @@ export interface RouterOptions {
additionalWorkspaceProviders?: Record<string, WorkspaceProvider>;
permissions?: PermissionsService;
permissionRules?: Array<ScaffolderPermissionRuleInput>;
auth?: AuthService;
httpAuth?: HttpAuthService;
identity?: IdentityApi;
discovery?: DiscoveryService;
auth: AuthService;
httpAuth: HttpAuthService;
events?: EventsService;
auditor?: AuditorService;
autocompleteHandlers?: Record<string, AutocompleteHandler>;
@@ -22,10 +22,14 @@ import {
hasProperty,
hasStringProperty,
hasTag,
hasCreatedBy,
hasTemplateEntityRefs,
} from './rules';
import { createConditionAuthorizer } from '@backstage/plugin-permission-node';
import { RESOURCE_TYPE_SCAFFOLDER_ACTION } from '@backstage/plugin-scaffolder-common/alpha';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { SerializedTask } from '@backstage/plugin-scaffolder-node';
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
describe('hasTag', () => {
describe('apply', () => {
@@ -523,3 +527,158 @@ describe('hasStringProperty', () => {
);
});
});
describe('hasCreatedBy', () => {
describe('apply', () => {
const task: SerializedTask = {
id: 'a-random-id',
spec: {} as TaskSpec,
status: 'completed',
createdAt: '',
createdBy: 'user:default/user-1',
};
it('returns false when createdBy is an empty array', () => {
expect(
hasCreatedBy.apply(task, {
createdBy: [],
}),
).toEqual(false);
});
it('returns false when createdBy is not matched (single user in createdBy)', () => {
expect(
hasCreatedBy.apply(task, {
createdBy: ['not-matched'],
}),
).toEqual(false);
});
it('returns true when createdBy matches (single user in createdBy)', () => {
expect(
hasCreatedBy.apply(task, {
createdBy: ['user:default/user-1'],
}),
).toEqual(true);
});
it('returns false when createdBy is not matched (multiple users in createdBy)', () => {
expect(
hasCreatedBy.apply(task, {
createdBy: [
'user:default/user-2',
'user:default/user-3',
'user:default/user-4',
],
}),
).toEqual(false);
});
it('returns true when createdBy matches (multiple users in createdBy)', () => {
expect(
hasCreatedBy.apply(task, {
createdBy: [
'user:default/user-1',
'user:default/user-2',
'user:default/user-3',
],
}),
).toEqual(true);
});
});
describe('toQuery', () => {
it('returns the correct query filter with values (single user in createdBy)', () => {
expect(
hasCreatedBy.toQuery({
createdBy: ['user:default/user-1'],
}),
).toEqual({ property: 'createdBy', values: ['user:default/user-1'] });
});
});
it('returns the correct query filter with values (multiple users in createdBy)', () => {
expect(
hasCreatedBy.toQuery({
createdBy: ['user:default/user-1', 'user:default/user-2'],
}),
).toEqual({
property: 'createdBy',
values: ['user:default/user-1', 'user:default/user-2'],
});
});
});
describe('hasTemplateEntityRefs', () => {
describe('apply', () => {
const task: SerializedTask = {
id: 'a-random-id',
spec: {
templateInfo: { entityRef: 'template:default/test-1' },
} as TaskSpec,
status: 'completed',
createdAt: '',
};
it('returns false when templateEntityRefs is an empty array', () => {
expect(
hasTemplateEntityRefs.apply(task, {
templateEntityRefs: [],
}),
).toEqual(false);
});
it('returns false when templateEntityRef is not matched (single entityRef in templateEntityRefs)', () => {
expect(
hasTemplateEntityRefs.apply(task, {
templateEntityRefs: ['template:default/not-matched'],
}),
).toEqual(false);
});
it('returns true when templateEntityRef matches (single entityRef in templateEntityRefs)', () => {
expect(
hasTemplateEntityRefs.apply(task, {
templateEntityRefs: ['template:default/test-1'],
}),
).toEqual(true);
});
it('returns false when templateEntityRefs is not matched (multiple entitRefs in templateEntityRefs)', () => {
expect(
hasTemplateEntityRefs.apply(task, {
templateEntityRefs: [
'template:default/test-2',
'template:default/test-3',
'template:default/test-4',
],
}),
).toEqual(false);
});
it('returns true when templateEntityRefs matches (multiple entityRefs in templateEntityRefs)', () => {
expect(
hasTemplateEntityRefs.apply(task, {
templateEntityRefs: [
'template:default/test-2',
'template:default/test-1',
'template:default/test-3',
],
}),
).toEqual(true);
});
});
describe('toQuery', () => {
it('returns the correct query filter with values (single entityRef in templateEntityRefs)', () => {
expect(
hasTemplateEntityRefs.toQuery({
templateEntityRefs: ['template:default/test-1'],
}),
).toEqual({
property: 'templateEntityRefs',
values: ['template:default/test-1'],
});
});
});
it('returns the correct query filter with values (multiple entityRefs in templateEntityRefs)', () => {
expect(
hasTemplateEntityRefs.toQuery({
templateEntityRefs: [
'template:default/test-1',
'template:default/test-2',
],
}),
).toEqual({
property: 'templateEntityRefs',
values: ['template:default/test-1', 'template:default/test-2'],
});
});
});
@@ -15,9 +15,11 @@
*/
import { makeCreatePermissionRule } from '@backstage/plugin-permission-node';
import {
RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
RESOURCE_TYPE_SCAFFOLDER_ACTION,
RESOURCE_TYPE_SCAFFOLDER_TASK,
} from '@backstage/plugin-scaffolder-common/alpha';
import {
@@ -25,6 +27,8 @@ import {
TemplateParametersV1beta3,
} from '@backstage/plugin-scaffolder-common';
import { SerializedTask, TaskFilter } from '@backstage/plugin-scaffolder-node';
import { z } from 'zod';
import { JsonObject, JsonPrimitive } from '@backstage/types';
import { get } from 'lodash';
@@ -129,6 +133,65 @@ function buildHasProperty<Schema extends z.ZodType<JsonPrimitive>>({
});
}
export const createTaskPermissionRule = makeCreatePermissionRule<
SerializedTask,
{
property: TaskFilter['property'];
values: any;
},
typeof RESOURCE_TYPE_SCAFFOLDER_TASK
>();
export const hasCreatedBy = createTaskPermissionRule({
name: 'HAS_CREATED_BY',
description: 'Allows tasks created by certain users to be accessible',
resourceType: RESOURCE_TYPE_SCAFFOLDER_TASK,
paramsSchema: z.object({
createdBy: z
.array(z.string())
.describe(
'List of creater entity refs; only tasks created by these users will be viewable',
),
}),
apply: (resource, { createdBy }) => {
if (!resource.createdBy) {
return false;
}
return createdBy.includes(resource.createdBy);
},
toQuery: ({ createdBy }) => {
return {
property: 'createdBy' as TaskFilter['property'],
values: createdBy,
};
},
});
export const hasTemplateEntityRefs = createTaskPermissionRule({
name: 'HAS_TEMPLATE_ENTITY_REFS',
description: 'Match tasks with the given template entity refs',
resourceType: RESOURCE_TYPE_SCAFFOLDER_TASK,
paramsSchema: z.object({
templateEntityRefs: z
.array(z.string())
.describe(
'List of template entity refs; only tasks related to these templates will be viewable',
),
}),
apply: (resource, { templateEntityRefs }) => {
if (!resource.spec.templateInfo) {
return false;
}
return templateEntityRefs.includes(resource.spec.templateInfo.entityRef);
},
toQuery: ({ templateEntityRefs }) => {
return {
property: 'templateEntityRefs' as TaskFilter['property'],
values: templateEntityRefs,
};
},
});
export const scaffolderTemplateRules = { hasTag };
export const scaffolderActionRules = {
hasActionId,
@@ -136,3 +199,4 @@ export const scaffolderActionRules = {
hasNumberProperty,
hasStringProperty,
};
export const scaffolderTaskRules = { hasCreatedBy, hasTemplateEntityRefs };
+10 -3
View File
@@ -12,6 +12,9 @@ export const actionExecutePermission: ResourcePermission<'scaffolder-action'>;
// @alpha
export const RESOURCE_TYPE_SCAFFOLDER_ACTION = 'scaffolder-action';
// @alpha
export const RESOURCE_TYPE_SCAFFOLDER_TASK = 'scaffolder-task';
// @alpha
export const RESOURCE_TYPE_SCAFFOLDER_TEMPLATE = 'scaffolder-template';
@@ -23,22 +26,26 @@ export const scaffolderPermissions: (
| BasicPermission
| ResourcePermission<'scaffolder-action'>
| ResourcePermission<'scaffolder-template'>
| ResourcePermission<'scaffolder-task'>
)[];
// @alpha
export const scaffolderTaskPermissions: BasicPermission[];
export const scaffolderTaskPermissions: (
| BasicPermission
| ResourcePermission<'scaffolder-task'>
)[];
// @alpha
export const scaffolderTemplatePermissions: ResourcePermission<'scaffolder-template'>[];
// @alpha
export const taskCancelPermission: BasicPermission;
export const taskCancelPermission: ResourcePermission<'scaffolder-task'>;
// @alpha
export const taskCreatePermission: BasicPermission;
// @alpha
export const taskReadPermission: BasicPermission;
export const taskReadPermission: ResourcePermission<'scaffolder-task'>;
// @alpha
export const templateManagementPermission: BasicPermission;
+1
View File
@@ -58,6 +58,7 @@
"@backstage/catalog-model": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-scaffolder-common": "workspace:^",
"@backstage/types": "workspace:^",
"@isomorphic-git/pgp-plugin": "^0.0.7",
+21
View File
@@ -9,6 +9,7 @@ import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
import { LoggerService } from '@backstage/backend-plugin-api';
import { Observable } from '@backstage/types';
import { PermissionCriteria } from '@backstage/plugin-permission-common';
import { Schema } from 'jsonschema';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { ScmIntegrations } from '@backstage/integration';
@@ -373,6 +374,7 @@ export interface TaskBroker {
order: 'asc' | 'desc';
field: string;
}[];
permissionFilters?: PermissionCriteria<TaskFilters>;
}): Promise<{
tasks: SerializedTask[];
totalTasks?: number;
@@ -464,6 +466,25 @@ export interface TaskContext {
// @public
export type TaskEventType = 'completion' | 'log' | 'cancelled' | 'recovered';
// @public
export type TaskFilter = {
property: 'createdBy' | 'templateEntityRefs';
values: Array<string> | undefined;
};
// @public
export type TaskFilters =
| {
anyOf: TaskFilter[];
}
| {
allOf: TaskFilter[];
}
| {
not: TaskFilter;
}
| TaskFilter;
// @public
export type TaskSecrets = Record<string, string> & {
backstageToken?: string;
@@ -18,6 +18,8 @@ export type {
TaskSecrets,
SerializedTask,
SerializedTaskEvent,
TaskFilter,
TaskFilters,
TaskBroker,
TaskBrokerDispatchOptions,
TaskBrokerDispatchResult,
@@ -15,6 +15,7 @@
*/
import { BackstageCredentials } from '@backstage/backend-plugin-api';
import { PermissionCriteria } from '@backstage/plugin-permission-common';
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
import { JsonObject, JsonValue, Observable } from '@backstage/types';
@@ -104,6 +105,25 @@ export type TaskBrokerDispatchOptions = {
createdBy?: string;
};
/**
* TaskFilter
* @public
*/
export type TaskFilter = {
property: 'createdBy' | 'templateEntityRefs';
values: Array<string> | undefined;
};
/**
* TaskFilters
* @public
*/
export type TaskFilters =
| { anyOf: TaskFilter[] }
| { allOf: TaskFilter[] }
| { not: TaskFilter }
| TaskFilter;
/**
* Task
*
@@ -194,6 +214,7 @@ export interface TaskBroker {
offset?: number;
};
order?: { order: 'asc' | 'desc'; field: string }[];
permissionFilters?: PermissionCriteria<TaskFilters>;
}): Promise<{ tasks: SerializedTask[]; totalTasks?: number }>;
/**
@@ -139,7 +139,6 @@ function OngoingTaskContent(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,
resourceRef: taskId,
+1
View File
@@ -7615,6 +7615,7 @@ __metadata:
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-scaffolder-common": "workspace:^"
"@backstage/types": "workspace:^"
"@isomorphic-git/pgp-plugin": "npm:^0.0.7"