feat: initial implementation of scaffolder granular permissions

Signed-off-by: Kashish Mittal <kmittal@redhat.com>
Co-authored-by: Frank Kong <frkong@redhat.com>
This commit is contained in:
Kashish Mittal
2025-03-12 18:35:41 -04:00
parent 9ad11f6555
commit 152ae1e3c5
11 changed files with 384 additions and 59 deletions
@@ -17,9 +17,14 @@
import {
RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
RESOURCE_TYPE_SCAFFOLDER_ACTION,
RESOURCE_TYPE_SCAFFOLDER_TASK,
} from '@backstage/plugin-scaffolder-common/alpha';
import { createConditionExports } from '@backstage/plugin-permission-node';
import { scaffolderTemplateRules, scaffolderActionRules } from './rules';
import {
scaffolderTemplateRules,
scaffolderActionRules,
scaffolderTaskRules,
} from './rules';
const templateConditionExports = createConditionExports({
pluginId: 'scaffolder',
@@ -33,6 +38,12 @@ const actionsConditionExports = createConditionExports({
rules: scaffolderActionRules,
});
const taskConditionExports = createConditionExports({
pluginId: 'scaffolder',
resourceType: RESOURCE_TYPE_SCAFFOLDER_TASK,
rules: scaffolderTaskRules,
});
/**
* `createScaffolderTemplateConditionalDecision` can be used when authoring policies to
* create conditional decisions. It requires a permission of type
@@ -90,3 +101,17 @@ export const createScaffolderActionConditionalDecision =
* @alpha
*/
export const scaffolderActionConditions = actionsConditionExports.conditions;
/**
* @alpha
*/
export const createScaffolderTaskConditionalDecision =
taskConditionExports.createConditionalDecision;
/**
* These conditions are used when creating conditional decisions for scaffolder
* tasks that are returned by authorization policies.
*
* @alpha
*/
export const scaffolderTaskConditions = taskConditionExports.conditions;
@@ -21,9 +21,24 @@ import {
} from '@backstage/plugin-scaffolder-common';
import {
RESOURCE_TYPE_SCAFFOLDER_ACTION,
RESOURCE_TYPE_SCAFFOLDER_TASK,
RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
} from '@backstage/plugin-scaffolder-common/alpha';
import { PermissionRuleParams } from '@backstage/plugin-permission-common';
import {
SerializedTask,
TaskFilter,
TaskFilters,
} from '@backstage/plugin-scaffolder-node';
/**
*
* @public
*/
export type ScaffolderPermissionRuleInput =
| TemplatePermissionRuleInput
| ActionPermissionRuleInput
| TaskPermissionRuleInput;
/**
* @public
@@ -59,3 +74,23 @@ export function isActionPermissionRuleInput(
): permissionRule is ActionPermissionRuleInput {
return permissionRule.resourceType === RESOURCE_TYPE_SCAFFOLDER_ACTION;
}
/**
* @public
*/
export type TaskPermissionRuleInput<
TParams extends PermissionRuleParams = PermissionRuleParams,
> = PermissionRule<
SerializedTask,
{
property: TaskFilter['property'];
values: any;
},
typeof RESOURCE_TYPE_SCAFFOLDER_TASK,
TParams
>;
export function isTaskPermissionRuleInput(
permissionRule: ScaffolderPermissionRuleInput,
): permissionRule is TaskPermissionRuleInput {
return permissionRule.resourceType === RESOURCE_TYPE_SCAFFOLDER_TASK;
}
@@ -945,6 +945,36 @@ describe('scaffolder router', () => {
order: [{ order: 'desc', field: 'created_at' }],
});
});
it('disallows users from seeing tasks they do not own', async () => {
const { router, taskBroker, permissions } = await createTestRouter();
jest
.spyOn(permissions, 'authorizeConditional')
.mockImplementationOnce(async () => [
{
conditions: {
resourceType: 'scaffolder-task',
rule: 'IS_TASK_OWNER',
params: { createdBy: ['user'] },
},
pluginId: 'scaffolder',
resourceType: 'scaffolder-task',
result: AuthorizeResult.CONDITIONAL,
},
]);
const response = await request(router).get(
`/v2/tasks?createdBy=not-user`,
);
expect(taskBroker.list).toHaveBeenCalledWith({
filters: { createdBy: ['not-user'], status: undefined },
order: undefined,
pagination: { limit: undefined, offset: undefined },
permissionFilters: { key: 'created_by', values: ['user'] },
});
expect(response.status).toBe(200);
expect(response.body.totalTasks).toBe(0);
expect(response.body.tasks).toEqual([]);
});
});
describe('GET /v2/tasks/:taskId', () => {
@@ -966,6 +996,37 @@ describe('scaffolder router', () => {
expect(response.body.status).toBe('completed');
expect(response.body.secrets).toBeUndefined();
});
it('disallows users from seeing tasks they do not own', async () => {
const { router, permissions, taskBroker } = await createTestRouter();
jest
.spyOn(permissions, 'authorizeConditional')
.mockImplementationOnce(async () => [
{
conditions: {
resourceType: 'scaffolder-task',
rule: 'IS_TASK_OWNER',
params: { createdBy: ['user'] },
},
pluginId: 'scaffolder',
resourceType: 'scaffolder-task',
result: AuthorizeResult.CONDITIONAL,
},
]);
(taskBroker.get as jest.Mocked<TaskBroker>['get']).mockResolvedValue({
id: 'a-random-id',
spec: {} as any,
status: 'completed',
createdAt: '',
secrets: {
__initiatorCredentials: JSON.stringify(credentials),
},
createdBy: 'not-user',
});
const response = await request(router).get(`/v2/tasks/a-random-id`);
expect(taskBroker.get).toHaveBeenCalledWith('a-random-id');
expect(response.error).not.toBeFalsy();
});
});
describe('GET /v2/tasks/:taskId/eventstream', () => {
@@ -1206,6 +1267,40 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
});
expect(subscriber!.closed).toBe(true);
});
it('disallows users from seeing events for tasks they do not own', async () => {
const { permissions, router, taskBroker } = await createTestRouter();
jest
.spyOn(permissions, 'authorizeConditional')
.mockImplementationOnce(async () => [
{
conditions: {
resourceType: 'scaffolder-task',
rule: 'IS_TASK_OWNER',
params: { createdBy: ['user'] },
},
pluginId: 'scaffolder',
resourceType: 'scaffolder-task',
result: AuthorizeResult.CONDITIONAL,
},
]);
(taskBroker.get as jest.Mocked<TaskBroker>['get']).mockResolvedValue({
id: 'a-random-id',
spec: {} as any,
status: 'completed',
createdAt: '',
secrets: {
__initiatorCredentials: JSON.stringify(credentials),
},
createdBy: 'not-user',
});
const response = await request(router).get(
`/v2/tasks/a-random-id/events`,
);
expect(taskBroker.get).toHaveBeenCalledWith('a-random-id');
expect(response.error).not.toBeFalsy();
});
});
describe('POST /v2/dry-run', () => {
@@ -1233,6 +1328,35 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
expect.anything(),
);
});
it('disallows users from seeing tasks they do not own', async () => {
const { permissions, router, taskBroker } = await createTestRouter();
jest
.spyOn(permissions, 'authorizeConditional')
.mockImplementationOnce(async () => [
{
conditions: {
resourceType: 'scaffolder-task',
rule: 'IS_TASK_OWNER',
params: { createdBy: ['user'] },
},
pluginId: 'scaffolder',
resourceType: 'scaffolder-task',
result: AuthorizeResult.CONDITIONAL,
},
]);
const response = await request(router).get(
`/v2/tasks?createdBy=not-user`,
);
expect(taskBroker.list).toHaveBeenCalledWith({
filters: { createdBy: ['not-user'], status: undefined },
order: undefined,
pagination: { limit: undefined, offset: undefined },
permissionFilters: { key: 'created_by', values: ['user'] },
});
expect(response.status).toBe(200);
expect(response.body.totalTasks).toBe(0);
expect(response.body.tasks).toEqual([]);
});
});
describe('GET /v2/autocomplete/:provider/:resource', () => {
@@ -42,6 +42,9 @@ import { EventsService } from '@backstage/plugin-events-node';
import {
createConditionAuthorizer,
createPermissionIntegrationRouter,
PermissionRule,
createConditionTransformer,
ConditionTransformer,
} from '@backstage/plugin-permission-node';
import {
TaskSpec,
@@ -51,7 +54,9 @@ import {
import {
RESOURCE_TYPE_SCAFFOLDER_ACTION,
RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
RESOURCE_TYPE_SCAFFOLDER_TASK,
scaffolderActionPermissions,
scaffolderTaskPermissions,
scaffolderPermissions,
scaffolderTemplatePermissions,
taskCancelPermission,
@@ -89,7 +94,11 @@ import {
import { createDryRunner } from '../scaffolder/dryrun';
import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker';
import { InternalTaskSecrets } from '../scaffolder/tasks/types';
import { checkPermission } from '../util/checkPermissions';
import {
checkPermission,
checkTaskPermission,
getAuthorizeConditions,
} from '../util/checkPermissions';
import {
findTemplate,
getEntityBaseUrl,
@@ -97,7 +106,7 @@ import {
parseNumberParam,
parseStringsParam,
} from './helpers';
import { scaffolderActionRules, scaffolderTemplateRules } from './rules';
import {
convertFiltersToRecord,
convertGlobalsToRecord,
@@ -107,6 +116,9 @@ import {
} from '../util/templating';
import { createDefaultFilters } from '../lib/templating/filters/createDefaultFilters';
import {
ScaffolderPermissionRuleInput,
TaskPermissionRuleInput,
isTaskPermissionRuleInput,
ActionPermissionRuleInput,
isActionPermissionRuleInput,
isTemplatePermissionRuleInput,
@@ -114,6 +126,12 @@ import {
} from './permissions';
import { CatalogService } from '@backstage/plugin-catalog-node';
import {
scaffolderActionRules,
scaffolderTemplateRules,
scaffolderTaskRules,
} from './rules';
/**
* RouterOptions
*/
@@ -139,11 +157,11 @@ export interface RouterOptions {
| CreatedTemplateGlobal[];
additionalWorkspaceProviders?: Record<string, WorkspaceProvider>;
permissions?: PermissionsService;
permissionRules?: Array<
TemplatePermissionRuleInput | ActionPermissionRuleInput
>;
auth: AuthService;
httpAuth: HttpAuthService;
permissionRules?: Array<ScaffolderPermissionRuleInput>;
auth?: AuthService;
httpAuth?: HttpAuthService;
identity?: IdentityApi;
discovery?: DiscoveryService;
events?: EventsService;
auditor?: AuditorService;
autocompleteHandlers?: Record<string, AutocompleteHandler>;
@@ -312,15 +330,24 @@ export async function createRouter(
const actionRules: ActionPermissionRuleInput[] = Object.values(
scaffolderActionRules,
);
const taskRules: TaskPermissionRuleInput[] =
Object.values(scaffolderTaskRules);
if (permissionRules) {
templateRules.push(
...permissionRules.filter(isTemplatePermissionRuleInput),
);
actionRules.push(...permissionRules.filter(isActionPermissionRuleInput));
taskRules.push(...permissionRules.filter(isTaskPermissionRuleInput));
}
const isAuthorized = createConditionAuthorizer(Object.values(templateRules));
const isTemplateAuthorized = createConditionAuthorizer(
Object.values(templateRules),
);
const isTaskAuthorized = createConditionAuthorizer(Object.values(taskRules));
const taskTransformConditions: ConditionTransformer<TaskFilters> =
createConditionTransformer(Object.values(taskRules));
const permissionIntegrationRouter = createPermissionIntegrationRouter({
resources: [
@@ -334,6 +361,18 @@ export async function createRouter(
permissions: scaffolderActionPermissions,
rules: actionRules,
},
{
resourceType: RESOURCE_TYPE_SCAFFOLDER_TASK,
permissions: scaffolderTaskPermissions,
rules: taskRules,
getResources: async resourceRefs => {
return Promise.all(
resourceRefs.map(async taskId => {
return await taskBroker.get(taskId);
}),
);
},
},
],
permissions: scaffolderPermissions,
});
@@ -532,11 +571,6 @@ export async function createRouter(
try {
const credentials = await httpAuth.credentials(req);
await checkPermission({
credentials,
permissions: [taskReadPermission],
permissionService: permissions,
});
if (!taskBroker.list) {
throw new Error(
@@ -564,6 +598,13 @@ export async function createRouter(
const limit = parseNumberParam(req.query.limit, 'limit');
const offset = parseNumberParam(req.query.offset, 'offset');
const taskPermissionFilters = await getAuthorizeConditions({
credentials: credentials,
permission: taskReadPermission,
permissionService: permissions,
transformConditions: taskTransformConditions,
});
const tasks = await taskBroker.list({
filters: {
createdBy,
@@ -574,6 +615,7 @@ export async function createRouter(
limit: limit ? limit[0] : undefined,
offset: offset ? offset[0] : undefined,
},
permissionFilters: taskPermissionFilters,
});
await auditorEvent?.success();
@@ -598,13 +640,17 @@ export async function createRouter(
try {
const credentials = await httpAuth.credentials(req);
await checkPermission({
credentials,
permissions: [taskReadPermission],
permissionService: permissions,
});
const task = await taskBroker.get(taskId);
await checkTaskPermission({
credentials,
permission: taskReadPermission,
permissionService: permissions,
task: task,
isTaskAuthorized,
});
if (!task) {
throw new NotFoundError(`Task with id ${taskId} does not exist`);
}
@@ -634,11 +680,13 @@ export async function createRouter(
try {
const credentials = await httpAuth.credentials(req);
// Requires both read and cancel permissions
await checkPermission({
const task = await taskBroker.get(taskId);
await checkTaskPermission({
credentials,
permissions: [taskCancelPermission, taskReadPermission],
permission: taskCancelPermission,
permissionService: permissions,
task: task,
isTaskAuthorized,
});
await taskBroker.cancel?.(taskId);
@@ -666,13 +714,23 @@ export async function createRouter(
try {
const credentials = await httpAuth.credentials(req);
const task = await taskBroker.get(taskId);
// Requires both read and cancel permissions
await checkPermission({
credentials,
permissions: [taskCreatePermission, taskReadPermission],
permissions: [taskCreatePermission],
permissionService: permissions,
});
await checkTaskPermission({
credentials,
permission: taskReadPermission,
permissionService: permissions,
task: task,
isTaskAuthorized,
});
await auditorEvent?.success();
const { token } = await auth.getPluginRequestToken({
@@ -711,10 +769,14 @@ export async function createRouter(
try {
const credentials = await httpAuth.credentials(req);
await checkPermission({
const task = await taskBroker.get(taskId);
await checkTaskPermission({
credentials,
permissions: [taskReadPermission],
permission: taskReadPermission,
permissionService: permissions,
task: task,
isTaskAuthorized,
});
const after =
@@ -783,10 +845,14 @@ export async function createRouter(
try {
const credentials = await httpAuth.credentials(req);
await checkPermission({
const task = await taskBroker.get(taskId);
await checkTaskPermission({
credentials,
permissions: [taskReadPermission],
permission: taskReadPermission,
permissionService: permissions,
task: task,
isTaskAuthorized,
});
const after = Number(req.query.after) || undefined;
@@ -1023,18 +1089,18 @@ export async function createRouter(
// Authorize parameters
if (Array.isArray(template.spec.parameters)) {
template.spec.parameters = template.spec.parameters.filter(step =>
isAuthorized(parameterDecision, step),
isTemplateAuthorized(parameterDecision, step),
);
} else if (
template.spec.parameters &&
!isAuthorized(parameterDecision, template.spec.parameters)
!isTemplateAuthorized(parameterDecision, template.spec.parameters)
) {
template.spec.parameters = undefined;
}
// Authorize steps
template.spec.steps = template.spec.steps.filter(step =>
isAuthorized(stepDecision, step),
isTemplateAuthorized(stepDecision, step),
);
return template;
@@ -21,7 +21,13 @@ import { NotAllowedError } from '@backstage/errors';
import {
AuthorizeResult,
BasicPermission,
PermissionCriteria,
PolicyDecision,
ResourcePermission,
} from '@backstage/plugin-permission-common';
import { ConditionTransformer } from '@backstage/plugin-permission-node';
import { SerializedTask } from '@backstage/plugin-scaffolder-node';
import { TaskFilters } from '@backstage/plugin-scaffolder-node';
export type checkPermissionOptions = {
credentials: BackstageCredentials;
@@ -29,6 +35,24 @@ export type checkPermissionOptions = {
permissionService?: PermissionsService;
};
export type checkTaskPermissionOptions = {
credentials: BackstageCredentials;
permission: ResourcePermission;
permissionService?: PermissionsService;
task: SerializedTask;
isTaskAuthorized: (
decision: PolicyDecision,
resource: SerializedTask | undefined,
) => boolean;
};
export type authorizeConditionsOptions = {
credentials: BackstageCredentials;
permission: ResourcePermission;
permissionService?: PermissionsService;
transformConditions: ConditionTransformer<TaskFilters>;
};
/**
* Does a basic check on permissions. Throws 403 error if any permission responds with AuthorizeResult.DENY
* @public
@@ -51,3 +75,45 @@ export async function checkPermission(options: checkPermissionOptions) {
}
}
}
/**
* Does a conditional permission check for scaffolder task reading and cancellation.
* Throws 403 error if permission responds with AuthorizeResult.DENY, or does not resolve to true during the conditional rule check
* @public
*/
export async function checkTaskPermission(options: checkTaskPermissionOptions) {
const { permission, permissionService, credentials, task, isTaskAuthorized } =
options;
if (permissionService) {
const [taskDecision] = await permissionService.authorizeConditional(
[{ permission: permission }],
{ credentials },
);
if (
taskDecision.result === AuthorizeResult.DENY ||
!isTaskAuthorized(taskDecision, task)
) {
throw new NotAllowedError();
}
}
}
/** Fetches and transforms authorization conditions into filters, or returns `undefined` if the decision is not conditional.
* @public
*/
export const getAuthorizeConditions = async (
options: authorizeConditionsOptions,
): Promise<PermissionCriteria<TaskFilters> | undefined> => {
const { permission, permissionService, credentials, transformConditions } =
options;
if (permissionService) {
const [taskDecision] = await permissionService.authorizeConditional(
[{ permission: permission }],
{ credentials },
);
if (taskDecision.result === AuthorizeResult.CONDITIONAL) {
return transformConditions(taskDecision.conditions);
}
}
return undefined;
};
@@ -30,6 +30,13 @@ export const RESOURCE_TYPE_SCAFFOLDER_TEMPLATE = 'scaffolder-template';
*/
export const RESOURCE_TYPE_SCAFFOLDER_ACTION = 'scaffolder-action';
/**
* Permission resource type which corresponds to scaffolder tasks
*
* @alpha
*/
export const RESOURCE_TYPE_SCAFFOLDER_TASK = 'scaffolder-task';
/**
* This permission is used to authorize actions that involve executing
* an action from a template.
@@ -89,6 +96,7 @@ export const taskReadPermission = createPermission({
attributes: {
action: 'read',
},
resourceType: RESOURCE_TYPE_SCAFFOLDER_TASK,
});
/**
@@ -111,6 +119,7 @@ export const taskCreatePermission = createPermission({
export const taskCancelPermission = createPermission({
name: 'scaffolder.task.cancel',
attributes: {},
resourceType: RESOURCE_TYPE_SCAFFOLDER_TASK,
});
/**
@@ -30,7 +30,6 @@ import Functions from '@material-ui/icons/Functions';
import MoreVert from '@material-ui/icons/MoreVert';
import { SyntheticEvent, useState } from 'react';
import { usePermission } from '@backstage/plugin-permission-react';
import { taskReadPermission } from '@backstage/plugin-scaffolder-common/alpha';
import { templateManagementPermission } from '@backstage/plugin-scaffolder-common/alpha';
import { scaffolderReactTranslationRef } from '../../../translation';
@@ -69,10 +68,6 @@ export function ScaffolderPageContextMenu(
const classes = useStyles();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
const { allowed: canReadTasks } = usePermission({
permission: taskReadPermission,
});
const { allowed: canManageTemplates } = usePermission({
permission: templateManagementPermission,
});
@@ -164,7 +159,7 @@ export function ScaffolderPageContextMenu(
/>
</MenuItem>
)}
{onTasksClicked && canReadTasks && (
{onTasksClicked && (
<MenuItem onClick={onTasksClicked}>
<ListItemIcon>
<List fontSize="small" />
@@ -91,14 +91,22 @@ const ListTaskPageContent = (props: MyTaskPageProps) => {
if (error) {
return (
<>
<ErrorPanel error={error} />
<EmptyState
missing="info"
title={t('listTaskPage.content.emptyState.title')}
description={t('listTaskPage.content.emptyState.description')}
/>
</>
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<OwnerListPicker
filter={ownerFilter}
onSelectOwner={id => setOwnerFilter(id)}
/>
</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>
<ErrorPanel error={error} />
<EmptyState
missing="info"
title={t('listTaskPage.content.emptyState.title')}
description={t('listTaskPage.content.emptyState.description')}
/>
</CatalogFilterLayout.Content>
</CatalogFilterLayout>
);
}
@@ -46,6 +46,7 @@ type ContextMenuProps = {
onStartOver?: () => void;
onToggleLogs?: (state: boolean) => void;
onToggleButtonBar?: (state: boolean) => void;
taskId?: string;
isCancelButtonDisabled: boolean;
onCancel: () => void;
};
@@ -67,6 +68,7 @@ export const ContextMenu = (props: ContextMenuProps) => {
onStartOver,
onToggleLogs,
onToggleButtonBar,
taskId
} = props;
const { getPageTheme } = useTheme();
const pageTheme = getPageTheme({ themeId: 'website' });
@@ -76,6 +78,7 @@ export const ContextMenu = (props: ContextMenuProps) => {
const { allowed: canReadTask } = usePermission({
permission: taskReadPermission,
resourceRef: taskId
});
const { allowed: canCreateTask } = usePermission({
@@ -46,7 +46,7 @@ import {
TaskSteps,
} from '@backstage/plugin-scaffolder-react/alpha';
import { useAsync } from '@react-hookz/web';
import { usePermission } from '@backstage/plugin-permission-react';
import { usePermission} from '@backstage/plugin-permission-react';
import {
taskCancelPermission,
taskCreatePermission,
@@ -142,10 +142,12 @@ function OngoingTaskContent(props: {
// 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,
});
const { allowed: canReadTask } = usePermission({
permission: taskReadPermission,
resourceRef: taskId,
});
const { allowed: canCreateTask } = usePermission({
@@ -269,6 +271,7 @@ function OngoingTaskContent(props: {
onRetry={triggerRetry}
onToggleLogs={setLogVisibleState}
onToggleButtonBar={setButtonBarVisibleState}
taskId={taskId}
onCancel={triggerCancel}
isCancelButtonDisabled={isCancelButtonDisabled}
/>
@@ -60,10 +60,7 @@ import {
CustomFieldsPage,
} from '../../alpha/components/TemplateEditorPage';
import { RequirePermission } from '@backstage/plugin-permission-react';
import {
taskReadPermission,
templateManagementPermission,
} from '@backstage/plugin-scaffolder-common/alpha';
import { templateManagementPermission } from '@backstage/plugin-scaffolder-common/alpha';
import { useApp } from '@backstage/core-plugin-api';
import { FormField, OpaqueFormField } from '@internal/scaffolder';
import { useAsync, useMountEffect } from '@react-hookz/web';
@@ -182,11 +179,9 @@ export const InternalRouter = (
<Route
path={scaffolderTaskRouteRef.path}
element={
<RequirePermission permission={taskReadPermission}>
<TaskPageComponent
TemplateOutputsComponent={TemplateOutputsComponent}
/>
</RequirePermission>
<TaskPageComponent
TemplateOutputsComponent={TemplateOutputsComponent}
/>
}
/>
<Route
@@ -230,11 +225,7 @@ export const InternalRouter = (
/>
<Route
path={scaffolderListTaskRouteRef.path}
element={
<RequirePermission permission={taskReadPermission}>
<ListTasksPage contextMenu={props.contextMenu} />
</RequirePermission>
}
element={<ListTasksPage contextMenu={props.contextMenu} />}
/>
<Route
path={editorRouteRef.path}