Merge pull request #17464 from strapi/poc/permissions-parametrized-actions

This commit is contained in:
Marc Roig 2023-08-16 08:51:33 +02:00 committed by GitHub
commit 04fc8a0875
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 701 additions and 64 deletions

View File

@ -0,0 +1,255 @@
'use strict';
const { mapAsync } = require('@strapi/utils');
const { createStrapiInstance } = require('api-tests/strapi');
const { createAuthRequest, createRequest } = require('api-tests/request');
const { createTestBuilder } = require('api-tests/builder');
const { describeOnCondition } = require('api-tests/utils');
const {
STAGE_MODEL_UID,
WORKFLOW_MODEL_UID,
ENTITY_STAGE_ATTRIBUTE,
} = require('../../../../packages/core/admin/ee/server/constants/workflows');
const { create } = require('lodash');
const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE';
const productUID = 'api::product.product';
const model = {
draftAndPublish: true,
pluginOptions: {},
singularName: 'product',
pluralName: 'products',
displayName: 'Product',
kind: 'collectionType',
attributes: {
name: {
type: 'string',
},
},
options: {
reviewWorkflows: true,
},
};
const baseWorkflow = {
contentTypes: [productUID],
stages: [{ name: 'Stage 1' }, { name: 'Stage 2' }],
};
const getStageTransitionPermissions = (roleIds) => {
return roleIds.map((roleId) => ({
action: 'admin::review-workflows.stage.transition',
role: roleId,
}));
};
describeOnCondition(edition === 'EE')('Review workflows', () => {
const builder = createTestBuilder();
let strapi;
let workflow;
let rq;
let roles;
const createWorkflow = async (data) => {
const name = `workflow-${Math.random().toString(36)}`;
const req = await rq.post('/admin/review-workflows/workflows?populate=*', {
body: { data: { name, ...data } },
});
const status = req.statusCode;
const error = req.body.error;
const workflow = req.body.data;
return { workflow, status, error };
};
const updateWorkflow = async (id, data) => {
const req = await rq.put(`/admin/review-workflows/workflows/${id}?populate=stages`, {
body: { data },
});
const status = req.statusCode;
const error = req.body.error;
const workflow = req.body.data;
return { workflow, status, error };
};
const deleteWorkflow = async (id) => {
const req = await rq.delete(`/admin/review-workflows/workflows/${id}`);
const status = req.statusCode;
const error = req.body.error;
const workflow = req.body.data;
return { workflow, status, error };
};
beforeAll(async () => {
await builder.addContentTypes([model]).build();
strapi = await createStrapiInstance();
rq = await createAuthRequest({ strapi });
workflow = await createWorkflow(baseWorkflow);
// Get default roles
const { body } = await rq.get('/admin/roles');
roles = body.data;
});
afterAll(async () => {
await strapi.destroy();
await builder.cleanup();
});
describe('Assign workflow permissions', () => {
// Create stage with permissions
test('Can assign new stage permissions', async () => {
const { workflow } = await createWorkflow({
...baseWorkflow,
stages: [
{
...baseWorkflow.stages[0],
permissions: getStageTransitionPermissions([roles[0].id, roles[1].id]),
},
baseWorkflow.stages[1],
],
});
expect(workflow.stages[0].permissions).toHaveLength(2);
});
// Can unassign a role
test('Can remove stage permissions', async () => {
// Create workflow with permissions to transition to role 0 and 1
const { workflow } = await createWorkflow({
...baseWorkflow,
stages: [
{
...baseWorkflow.stages[0],
permissions: getStageTransitionPermissions([roles[0].id, roles[1].id]),
},
baseWorkflow.stages[1],
],
});
// Update workflow to remove role 1
const { workflow: updatedWorkflow } = await updateWorkflow(workflow.id, {
stages: [
{
...workflow.stages[0],
permissions: getStageTransitionPermissions([roles[0].id]),
},
workflow.stages[1],
],
});
// Validate that permissions have been removed from database
const deletedPermission = await strapi.query('admin::permission').findOne({
where: {
id: workflow.stages[0].permissions[1].id,
},
});
expect(updatedWorkflow.stages[0].permissions).toHaveLength(1);
expect(deletedPermission).toBeNull();
});
test('Deleting stage removes permissions', async () => {
const { workflow } = await createWorkflow({
...baseWorkflow,
stages: [
{
...baseWorkflow.stages[0],
permissions: getStageTransitionPermissions([roles[0].id, roles[1].id]),
},
baseWorkflow.stages[1],
],
});
const { workflow: updatedWorkflow } = await updateWorkflow(workflow.id, {
...workflow,
stages: [workflow.stages[1]],
});
// Deleted stage permissions should be removed from database
const permissions = await strapi.query('admin::permission').findMany({
where: {
id: { $in: workflow.stages[0].permissions.map((p) => p.id) },
},
});
expect(permissions).toHaveLength(0);
});
test('Deleting workflow removes permissions', async () => {
const { workflow } = await createWorkflow({
...baseWorkflow,
stages: [
{
...baseWorkflow.stages[0],
permissions: getStageTransitionPermissions([roles[0].id, roles[1].id]),
},
],
});
await deleteWorkflow(workflow.id);
// Deleted workflow permissions should be removed from database
const permissions = await strapi.query('admin::permission').findMany({
where: {
id: { $in: workflow.stages[0].permissions.map((p) => p.id) },
},
});
expect(permissions).toHaveLength(0);
});
test('Fails when using invalid action', async () => {
const { status, error } = await createWorkflow({
...baseWorkflow,
stages: [
{
...baseWorkflow.stages[0],
permissions: [{ action: 'invalid-action', role: roles[0].id }],
},
],
});
expect(status).toBe(400);
expect(error.name).toBe('ValidationError');
});
// TODO
test.skip('Can send permissions as undefined to apply partial update', async () => {
// Creates workflow with permissions
const { workflow } = await createWorkflow({
...baseWorkflow,
stages: [
{
...baseWorkflow.stages[0],
permissions: [{ action: 'invalid-action', role: roles[0].id }],
},
],
});
const { workflow: updatedWorkflow } = await updateWorkflow(workflow.id, {
...workflow,
stages: [
{
...workflow.stages[0],
permissions: undefined,
},
],
});
// Permissions should be kept
expect(updatedWorkflow.stages[0].permissions).toHaveLength(1);
});
});
});

View File

@ -1,5 +1,6 @@
'use strict';
const { omit } = require('lodash/fp');
const { mapAsync } = require('@strapi/utils');
const { createStrapiInstance } = require('api-tests/strapi');
@ -439,11 +440,15 @@ describeOnCondition(edition === 'EE')('Review workflows', () => {
expect(workflowRes.status).toBe(200);
expect(workflowRes.body.data).toBeInstanceOf(Object);
expect(workflowRes.body.data.stages).toBeInstanceOf(Array);
expect(workflowRes.body.data.stages[0]).toMatchObject(stagesUpdateData[0]);
expect(workflowRes.body.data.stages[1]).toMatchObject(stagesUpdateData[1]);
expect(workflowRes.body.data.stages[0]).toMatchObject(
omit(['updatedAt'], stagesUpdateData[0])
);
expect(workflowRes.body.data.stages[1]).toMatchObject(
omit(['updatedAt'], stagesUpdateData[1])
);
expect(workflowRes.body.data.stages[2]).toMatchObject({
id: expect.any(Number),
...stagesUpdateData[2],
...omit(['updatedAt'], stagesUpdateData[2]),
});
} else {
expect(workflowRes.status).toBe(404);

View File

@ -133,6 +133,9 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
expect(reducer(state, action)).toStrictEqual(
expect.objectContaining({
serverState: expect.objectContaining({
workflow: WORKFLOW_FIXTURE,
}),
clientState: expect.objectContaining({
currentWorkflow: expect.objectContaining({
data: expect.objectContaining({

View File

@ -62,5 +62,11 @@ module.exports = {
category: 'review workflows',
subCategory: 'options',
},
{
uid: 'review-workflows.stage.transition',
displayName: 'Change stage',
pluginName: 'admin',
section: 'internal',
},
],
};

View File

@ -4,6 +4,7 @@
module.exports = {
WORKFLOW_MODEL_UID: 'admin::workflow',
STAGE_MODEL_UID: 'admin::workflow-stage',
STAGE_TRANSITION_UID: 'admin::review-workflows.stage.transition',
STAGE_DEFAULT_COLOR: '#4945FF',
ENTITY_STAGE_ATTRIBUTE: 'strapi_stage',
MAX_WORKFLOWS: 200,
@ -16,4 +17,16 @@ module.exports = {
'Youve reached the limit of stages for this workflow in your plan. Try deleting some stages or contact Sales to enable more stages.',
DUPLICATED_STAGE_NAME: 'Stage names must be unique.',
},
WORKFLOW_POPULATE: {
stages: {
populate: {
permissions: {
fields: ['action', 'actionParameters'],
populate: {
role: { fields: ['id', 'name'] },
},
},
},
},
},
};

View File

@ -40,6 +40,12 @@ module.exports = {
inversedBy: 'stages',
configurable: false,
},
permissions: {
type: 'relation',
target: 'admin::permission',
relation: 'manyToMany',
configurable: false,
}
},
},
};

View File

@ -1,5 +1,6 @@
'use strict';
const { update, map, property } = require('lodash/fp');
const { mapAsync } = require('@strapi/utils');
const { getService } = require('../../utils');
@ -7,7 +8,7 @@ const {
validateWorkflowCreate,
validateWorkflowUpdate,
} = require('../../validation/review-workflows');
const { WORKFLOW_MODEL_UID } = require('../../constants/workflows');
const { WORKFLOW_MODEL_UID, WORKFLOW_POPULATE } = require('../../constants/workflows');
/**
*
@ -22,6 +23,21 @@ function getWorkflowsPermissionChecker({ strapi }, userAbility) {
.create({ userAbility, model: WORKFLOW_MODEL_UID });
}
/**
* Transforms workflow to an admin UI format.
* Some attributes (like permissions) are presented in a different format in the admin UI.
* @param {Workflow} workflow
*/
function formatWorkflowToAdmin(workflow) {
if (!workflow) return;
if (!workflow.stages) return workflow;
// Transform permissions roles to be the id string instead of an object
const transformPermissions = map(update('role', property('id')));
const transformStages = map(update('permissions', transformPermissions));
return update('stages', transformStages, workflow);
}
module.exports = {
/**
* Create a new workflow
@ -38,10 +54,12 @@ module.exports = {
const workflowBody = await validateWorkflowCreate(body.data);
const workflowService = getService('workflows');
const createdWorkflow = await workflowService.create({
data: await sanitizeCreateInput(workflowBody),
populate,
});
const createdWorkflow = await workflowService
.create({
data: await sanitizeCreateInput(workflowBody),
populate,
})
.then(formatWorkflowToAdmin);
ctx.body = {
data: await sanitizeOutput(createdWorkflow),
@ -61,22 +79,27 @@ module.exports = {
ctx.state.userAbility
);
const { populate } = await sanitizedQuery.update(query);
const workflowBody = await validateWorkflowUpdate(body.data);
const workflow = await workflowService.findById(id, { populate: ['stages'] });
// Find if workflow exists
const workflow = await workflowService.findById(id, { populate: WORKFLOW_POPULATE });
if (!workflow) {
return ctx.notFound();
}
const getPermittedFieldToUpdate = sanitizeUpdateInput(workflow);
// Sanitize input data
const getPermittedFieldToUpdate = sanitizeUpdateInput(workflow);
const dataToUpdate = await getPermittedFieldToUpdate(workflowBody);
const updatedWorkflow = await workflowService.update(workflow, {
data: dataToUpdate,
populate,
});
// Update workflow
const updatedWorkflow = await workflowService
.update(workflow, {
data: dataToUpdate,
populate,
})
.then(formatWorkflowToAdmin);
// Send sanitized response
ctx.body = {
data: await sanitizeOutput(updatedWorkflow),
};
@ -96,12 +119,14 @@ module.exports = {
);
const { populate } = await sanitizedQuery.delete(query);
const workflow = await workflowService.findById(id, { populate: ['stages'] });
const workflow = await workflowService.findById(id, { populate: WORKFLOW_POPULATE });
if (!workflow) {
return ctx.notFound("Workflow doesn't exist");
}
const deletedWorkflow = await workflowService.delete(workflow, { populate });
const deletedWorkflow = await workflowService
.delete(workflow, { populate })
.then(formatWorkflowToAdmin);
ctx.body = {
data: await sanitizeOutput(deletedWorkflow),
@ -122,7 +147,7 @@ module.exports = {
const { populate, filters, sort } = await sanitizedQuery.read(query);
const [workflows, workflowCount] = await Promise.all([
workflowService.find({ populate, filters, sort }),
workflowService.find({ populate, filters, sort }).then(map(formatWorkflowToAdmin)),
workflowService.count(),
]);
@ -151,7 +176,7 @@ module.exports = {
const workflowService = getService('workflows');
const [workflow, workflowCount] = await Promise.all([
workflowService.findById(id, { populate }),
workflowService.findById(id, { populate }).then(formatWorkflowToAdmin),
workflowService.count(),
]);

View File

@ -3,7 +3,11 @@
const { mapAsync } = require('@strapi/utils');
const { getService } = require('../../../utils');
const { validateUpdateStageOnEntity } = require('../../../validation/review-workflows');
const { STAGE_MODEL_UID } = require('../../../constants/workflows');
const {
STAGE_MODEL_UID,
ENTITY_STAGE_ATTRIBUTE,
STAGE_TRANSITION_UID,
} = require('../../../constants/workflows');
/**
*
@ -75,18 +79,36 @@ module.exports = {
*/
async updateEntity(ctx) {
const stagesService = getService('stages');
const stagePermissions = getService('stage-permissions');
const workflowService = getService('workflows');
const { model_uid: modelUID, id: entityIdString } = ctx.params;
const { model_uid: modelUID, id } = ctx.params;
const { body } = ctx.request;
const entityId = Number(entityIdString);
const { sanitizeOutput } = strapi
.plugin('content-manager')
.service('permission-checker')
.create({ userAbility: ctx.state.userAbility, model: modelUID });
// Load entity
const entity = await strapi.entityService.findOne(modelUID, Number(id), {
populate: [ENTITY_STAGE_ATTRIBUTE],
});
if (!entity) {
ctx.throw(404, 'Entity not found');
}
// Validate if entity stage can be updated
const canTransition = stagePermissions.can(
STAGE_TRANSITION_UID,
entity[ENTITY_STAGE_ATTRIBUTE]?.id
);
if (!canTransition) {
ctx.throw(403, 'Forbidden stage transition');
}
const { id: stageId } = await validateUpdateStageOnEntity(
{ id: Number(body?.data?.id) },
'You should pass an id to the body of the put request.'
@ -95,8 +117,8 @@ module.exports = {
const workflow = await workflowService.assertContentTypeBelongsToWorkflow(modelUID);
workflowService.assertStageBelongsToWorkflow(stageId, workflow);
const entity = await stagesService.updateEntity({ id: entityId, modelUID }, stageId);
const updatedEntity = await stagesService.updateEntity({ id: entity.id, modelUID }, stageId);
ctx.body = { data: await sanitizeOutput(entity) };
ctx.body = { data: await sanitizeOutput(updatedEntity) };
},
};

View File

@ -78,6 +78,12 @@ const servicesMock = {
validateWorkflowCount: jest.fn().mockResolvedValue(true),
validateWorkflowStages: jest.fn(),
},
'admin::stage-permissions': {
register: jest.fn(),
registerMany: jest.fn(),
unregister: jest.fn(),
can: jest.fn(() => true),
},
};
const queryUpdateMock = jest.fn(() => Promise.resolve());

View File

@ -24,7 +24,7 @@ jest.mock('../review-workflows/workflows/content-types', () => {
});
const workflowsServiceFactory = require('../review-workflows/workflows');
const { WORKFLOW_MODEL_UID } = require('../../constants/workflows');
const { WORKFLOW_MODEL_UID, WORKFLOW_POPULATE } = require('../../constants/workflows');
const workflowMock = {
id: 1,
@ -71,6 +71,12 @@ const servicesMock = {
replaceStages: jest.fn(async () => stagesMock),
createMany: jest.fn(async () => stagesMock),
},
'admin::stage-permissions': {
register: jest.fn(),
registerMany: jest.fn(),
unregister: jest.fn(),
can: jest.fn(() => true),
},
};
const strapiMock = {
@ -145,7 +151,7 @@ describe('Review workflows - Workflows service', () => {
return stage;
}),
},
populate: undefined,
populate: WORKFLOW_POPULATE,
};
test('Should call entityService with the right model UID', async () => {
@ -158,7 +164,7 @@ describe('Review workflows - Workflows service', () => {
name: 'Default',
stages: workflow.stages.map((stage) => stage.id),
},
populate: undefined,
populate: WORKFLOW_POPULATE,
});
expect(servicesMock['admin::review-workflows-metrics'].sendDidEditWorkflow).toBeCalled();
});
@ -177,7 +183,7 @@ describe('Review workflows - Workflows service', () => {
},
],
},
populate: undefined,
populate: WORKFLOW_POPULATE,
};
test('Should call entityService with the right model UID', async () => {
@ -189,7 +195,7 @@ describe('Review workflows - Workflows service', () => {
name: 'Workflow',
stages: stagesMock.map((stage) => stage.id),
},
populate: { stages: true },
populate: WORKFLOW_POPULATE,
});
expect(servicesMock['admin::review-workflows-metrics'].sendDidCreateWorkflow).toBeCalled();
});

View File

@ -8,6 +8,7 @@ module.exports = {
'seat-enforcement': require('./seat-enforcement'),
workflows: require('./review-workflows/workflows'),
stages: require('./review-workflows/stages'),
'stage-permissions': require('./review-workflows/stage-permissions'),
'review-workflows': require('./review-workflows/review-workflows'),
'review-workflows-validation': require('./review-workflows/validation'),
'review-workflows-decorator': require('./review-workflows/entity-service-decorator'),

View File

@ -0,0 +1,60 @@
'use strict';
const { prop } = require('lodash/fp');
const {
mapAsync,
errors: { ApplicationError },
} = require('@strapi/utils');
const { getService } = require('../../utils');
const { STAGE_TRANSITION_UID } = require('../../constants/workflows');
const validActions = [STAGE_TRANSITION_UID];
module.exports = ({ strapi }) => {
const roleService = getService('role');
const permissionService = getService('permission');
return {
async register(roleId, action, fromStage) {
if (!validActions.includes(action)) {
throw new ApplicationError(`Invalid action ${action}`);
}
const permissions = await roleService.addPermissions(roleId, [
{
action,
actionParameters: {
from: fromStage,
},
},
]);
// TODO: Filter response
return permissions;
},
async registerMany(permissions) {
return mapAsync(permissions, this.register);
},
async unregister(permissions) {
const permissionIds = permissions.map(prop('id'));
await permissionService.deleteByIds(permissionIds);
},
can(action, fromStage) {
const requestState = strapi.requestContext.get()?.state;
if (!requestState) {
return false;
}
// Override permissions for super admin
const userRoles = requestState.user?.roles;
if (userRoles?.some((role) => role.code === 'strapi-super-admin')) {
return true;
}
return requestState.userAbility.can({
name: action,
params: { from: fromStage },
});
},
};
};

View File

@ -2,15 +2,20 @@
const {
mapAsync,
reduceAsync,
errors: { ApplicationError, ValidationError },
} = require('@strapi/utils');
const { map } = require('lodash/fp');
const { map, pick, isEqual } = require('lodash/fp');
const { STAGE_MODEL_UID, ENTITY_STAGE_ATTRIBUTE, ERRORS } = require('../../constants/workflows');
const { getService } = require('../../utils');
const sanitizedStageFields = ['id', 'name', 'workflow'];
const sanitizeStageFields = pick(sanitizedStageFields);
module.exports = ({ strapi }) => {
const metrics = getService('review-workflows-metrics', { strapi });
const stagePermissionsService = getService('stage-permissions', { strapi });
const workflowsValidationService = getService('review-workflows-validation', { strapi });
return {
@ -32,20 +37,64 @@ module.exports = ({ strapi }) => {
async createMany(stagesList, { fields } = {}) {
const params = { select: fields ?? '*' };
// TODO: pick the fields from the stage
const stages = await Promise.all(
stagesList.map((stage) =>
strapi.entityService.create(STAGE_MODEL_UID, { data: stage, ...params })
strapi.entityService.create(STAGE_MODEL_UID, {
data: sanitizeStageFields(stage),
...params,
})
)
);
// Create stage permissions
await reduceAsync(stagesList)(async (_, stage, idx) => {
// Ignore stages without permissions
if (!stage.permissions || stage.permissions.length === 0) {
return;
}
const stagePermissions = stage.permissions;
const stageId = stages[idx].id;
const permissions = await mapAsync(
stagePermissions,
// Register each stage permission
(permission) =>
stagePermissionsService.register(permission.role, permission.action, stageId)
);
// Update stage with the new permissions
await strapi.entityService.update(STAGE_MODEL_UID, stageId, {
data: {
permissions: permissions.flat().map((p) => p.id),
},
});
}, []);
metrics.sendDidCreateStage();
return stages;
},
async update(stageId, stageData) {
async update(srcStage, destStage) {
let stagePermissions = [];
const stageId = destStage.id;
await this.deleteStagePermissions([srcStage]);
if (destStage.permissions) {
const permissions = await mapAsync(destStage.permissions, (permission) =>
stagePermissionsService.register(permission.role, permission.action, stageId)
);
stagePermissions = permissions.flat().map((p) => p.id);
}
const stage = await strapi.entityService.update(STAGE_MODEL_UID, stageId, {
data: stageData,
data: {
...destStage,
permissions: stagePermissions,
},
});
metrics.sendDidEditStage();
@ -53,20 +102,31 @@ module.exports = ({ strapi }) => {
return stage;
},
async delete(stageId) {
const stage = await strapi.entityService.delete(STAGE_MODEL_UID, stageId);
async delete(stage) {
// Unregister all permissions related to this stage id
await this.deleteStagePermissions([stage]);
const deletedStage = await strapi.entityService.delete(STAGE_MODEL_UID, stage.id);
metrics.sendDidDeleteStage();
return stage;
return deletedStage;
},
async deleteMany(stagesId) {
async deleteMany(stages) {
await this.deleteStagePermissions(stages);
return strapi.entityService.deleteMany(STAGE_MODEL_UID, {
filters: { id: { $in: stagesId } },
filters: { id: { $in: stages.map((s) => s.id) } },
});
},
async deleteStagePermissions(stages) {
// TODO: Find another way to do this for when we use the "to" parameter.
const permissions = stages.map((s) => s.permissions || []).flat();
await stagePermissionsService.unregister(permissions || []);
},
count({ workflowId } = {}) {
const opts = {};
@ -91,7 +151,10 @@ module.exports = ({ strapi }) => {
const createdStagesIds = map('id', createdStages);
// Update the workflow stages
await mapAsync(updated, (stage) => this.update(stage.id, stage));
await mapAsync(updated, (destStage) => {
const srcStage = srcStages.find((s) => s.id === destStage.id);
return this.update(srcStage, destStage);
});
// Delete the stages that are not in the new stages list
await mapAsync(deleted, async (stage) => {
@ -114,7 +177,7 @@ module.exports = ({ strapi }) => {
});
});
return this.delete(stage.id);
return this.delete(stage);
});
return destStages.map((stage) => ({ ...stage, id: stage.id ?? createdStagesIds.shift() }));
@ -241,12 +304,19 @@ module.exports = ({ strapi }) => {
*/
function getDiffBetweenStages(sourceStages, comparisonStages) {
const result = comparisonStages.reduce(
// ...
(acc, stageToCompare) => {
const srcStage = sourceStages.find((stage) => stage.id === stageToCompare.id);
if (!srcStage) {
acc.created.push(stageToCompare);
} else if (srcStage.name !== stageToCompare.name || srcStage.color !== stageToCompare.color) {
} else if (
!isEqual(
pick(['name', 'color', 'permissions'], srcStage),
pick(['name', 'color', 'permissions'], stageToCompare)
)
) {
acc.updated.push(stageToCompare);
}
return acc;

View File

@ -2,7 +2,7 @@
const { set, isString, map, get } = require('lodash/fp');
const { ApplicationError } = require('@strapi/utils').errors;
const { WORKFLOW_MODEL_UID } = require('../../../constants/workflows');
const { WORKFLOW_MODEL_UID, WORKFLOW_POPULATE } = require('../../../constants/workflows');
const { getService } = require('../../../utils');
const { getWorkflowContentTypeFilter } = require('../../../utils/review-workflows');
const workflowsContentTypesFactory = require('./content-types');
@ -17,6 +17,16 @@ const processFilters = ({ strapi }, filters = {}) => {
return processedFilters;
};
// TODO: How can we improve this? Maybe using traversePopulate?
const processPopulate = (populate) => {
// If it does not exist or it's not an object (like an array) return the default populate
if (!populate) {
return populate;
}
return WORKFLOW_POPULATE;
};
module.exports = ({ strapi }) => {
const workflowsContentTypes = workflowsContentTypesFactory({ strapi });
const workflowsValidationService = getService('review-workflows-validation', { strapi });
@ -31,7 +41,9 @@ module.exports = ({ strapi }) => {
*/
async find(opts = {}) {
const filters = processFilters({ strapi }, opts.filters);
return strapi.entityService.findMany(WORKFLOW_MODEL_UID, { ...opts, filters });
const populate = processPopulate(opts.populate);
return strapi.entityService.findMany(WORKFLOW_MODEL_UID, { ...opts, filters, populate });
},
/**
@ -41,7 +53,8 @@ module.exports = ({ strapi }) => {
* @returns {Promise<object>} - Workflow object matching the requested ID.
*/
findById(id, opts) {
return strapi.entityService.findOne(WORKFLOW_MODEL_UID, id, opts);
const populate = processPopulate(opts.populate);
return strapi.entityService.findOne(WORKFLOW_MODEL_UID, id, { ...opts, populate });
},
/**
@ -51,7 +64,7 @@ module.exports = ({ strapi }) => {
* @throws {ValidationError} - If the workflow has no stages.
*/
async create(opts) {
let createOpts = { ...opts, populate: { stages: true } };
let createOpts = { ...opts, populate: WORKFLOW_POPULATE };
workflowsValidationService.validateWorkflowStages(opts.data.stages);
await workflowsValidationService.validateWorkflowCount(1);
@ -87,7 +100,7 @@ module.exports = ({ strapi }) => {
*/
async update(workflow, opts) {
const stageService = getService('stages', { strapi });
let updateOpts = { ...opts, populate: { stages: true } };
let updateOpts = { ...opts, populate: { ...WORKFLOW_POPULATE } };
let updatedStageIds;
await workflowsValidationService.validateWorkflowCount();
@ -104,7 +117,7 @@ module.exports = ({ strapi }) => {
.replaceStages(workflow.stages, opts.data.stages, workflow.contentTypes)
.then((stages) => stages.map((stage) => stage.id));
updateOpts = set('data.stages', updatedStageIds, opts);
updateOpts = set('data.stages', updatedStageIds, updateOpts);
}
// Update (un)assigned Content Types
@ -141,7 +154,7 @@ module.exports = ({ strapi }) => {
return strapi.db.transaction(async () => {
// Delete stages
await stageService.deleteMany(workflow.stages.map((stage) => stage.id));
await stageService.deleteMany(workflow.stages);
// Unassign all content types, this will migrate the content types to null
await workflowsContentTypes.migrate({

View File

@ -4,11 +4,22 @@
const { yup, validateYupSchema } = require('@strapi/utils');
const { hasStageAttribute } = require('../utils/review-workflows');
const { STAGE_TRANSITION_UID } = require('../constants/workflows');
const stageObject = yup.object().shape({
id: yup.number().integer().min(1),
name: yup.string().max(255).required(),
color: yup.string().matches(/^#(?:[0-9a-fA-F]{3}){1,2}$/i), // hex color
permissions: yup.array().of(
yup.object().shape({
role: yup.number().integer().min(1).required(),
action: yup.string().oneOf([STAGE_TRANSITION_UID]).required(),
actionParameters: yup.object().shape({
from: yup.number().integer().min(1).required(),
to: yup.number().integer().min(1),
}),
})
),
});
const validateUpdateStageOnEntity = yup

View File

@ -29,6 +29,12 @@ module.exports = {
configurable: false,
required: true,
},
actionParameters: {
type: 'json',
configurable: false,
required: false,
default: {},
},
subject: {
type: 'string',
minLength: 1,

View File

@ -26,8 +26,16 @@ const {
* @property {string} subject - The subject on which the permission should applies
*/
const permissionFields = ['id', 'action', 'subject', 'properties', 'conditions', 'role'];
const sanitizedPermissionFields = ['id', 'action', 'subject', 'properties', 'conditions'];
const permissionFields = [
'id',
'action',
'actionParameters',
'subject',
'properties',
'conditions',
'role',
];
const sanitizedPermissionFields = ['id', 'action', 'actionParameters', 'subject', 'properties', 'conditions'];
const sanitizePermissionFields = pick(sanitizedPermissionFields);
@ -36,6 +44,7 @@ const sanitizePermissionFields = pick(sanitizedPermissionFields);
* @return {Permission}
*/
const getDefaultPermission = () => ({
actionParameters: {},
conditions: [],
properties: {},
subject: null,

View File

@ -232,6 +232,7 @@ describe('Content-Type', () => {
expect(resultLevel1).toEqual([
{
action: 'action-1',
actionParameters: {},
subject: 'country',
properties: { fields: ['name', 'code'] },
conditions: [],
@ -253,12 +254,14 @@ describe('Content-Type', () => {
expect(resultLevel1).toEqual([
{
action: 'action-1',
actionParameters: {},
subject: 'country',
properties: { fields: ['name', 'code'] },
conditions: [],
},
{
action: 'action-1',
actionParameters: {},
subject: 'user',
properties: { fields: ['firstname', 'restaurant', 'car'] },
conditions: [],
@ -280,12 +283,14 @@ describe('Content-Type', () => {
expect(resultLevel1).toEqual([
{
action: 'action-1',
actionParameters: {},
subject: 'country',
properties: { fields: ['name', 'code'] },
conditions: [],
},
{
action: 'action-1',
actionParameters: {},
subject: 'user',
properties: {
fields: [
@ -312,12 +317,14 @@ describe('Content-Type', () => {
expect(resultLevel1).toEqual([
{
action: 'action-1',
actionParameters: {},
subject: 'country',
properties: { fields: ['name', 'code'] },
conditions: [],
},
{
action: 'action-1',
actionParameters: {},
subject: 'user',
properties: {
fields: [
@ -387,6 +394,7 @@ describe('Content-Type', () => {
expect(res).toEqual([
{
action: 'foo',
actionParameters: {},
subject: 'user',
properties: { fields: expectedFields },
conditions: [],

View File

@ -85,34 +85,40 @@ describe('Permission Service', () => {
{
id: 1,
action: 'action-1',
actionParameters: {},
properties: { fields: ['name'] },
},
{
id: 2,
action: 'action-2',
actionParameters: {},
properties: { fields: ['name'] },
},
{
id: 3,
action: 'action-3',
actionParameters: {},
subject: 'country',
properties: { fields: ['name'] },
},
{
id: 4,
action: 'action-3',
actionParameters: {},
subject: 'planet',
properties: { fields: ['name'] },
},
{
id: 5,
action: 'action-1',
actionParameters: {},
subject: 'planet',
properties: { fields: ['name', 'description'] },
},
{
id: 6,
action: 'action-1',
actionParameters: {},
subject: 'country',
properties: { fields: null },
},

View File

@ -404,6 +404,7 @@ describe('Role', () => {
const permissions = [
{
action: 'action-1',
actionParameters: {},
subject: 'country',
properties: { fields: ['name'] },
conditions: [],
@ -413,36 +414,42 @@ describe('Role', () => {
const defaultPermissions = [
{
action: 'plugin::upload.read',
actionParameters: {},
conditions: ['admin::is-creator'],
properties: {},
subject: null,
},
{
action: 'plugin::upload.configure-view',
actionParameters: {},
conditions: [],
properties: {},
subject: null,
},
{
action: 'plugin::upload.assets.create',
actionParameters: {},
conditions: [],
properties: {},
subject: null,
},
{
action: 'plugin::upload.assets.update',
actionParameters: {},
conditions: ['admin::is-creator'],
properties: {},
subject: null,
},
{
action: 'plugin::upload.assets.download',
actionParameters: {},
conditions: [],
properties: {},
subject: null,
},
{
action: 'plugin::upload.assets.copy-link',
actionParameters: {},
conditions: [],
properties: {},
subject: null,
@ -625,24 +632,28 @@ describe('Role', () => {
const permissions = [
{
action: 'action-1',
actionParameters: {},
subject: 'country',
properties: { fields: ['name'] },
conditions: [],
},
{
action: 'action-test2',
actionParameters: {},
subject: 'test-subject1',
properties: {},
conditions: [],
},
{
action: 'action-test2',
actionParameters: {},
subject: 'test-subject2',
properties: {},
conditions: [],
},
{
action: 'action-test3',
actionParameters: {},
subject: null,
properties: {},
conditions: [],
@ -759,11 +770,46 @@ describe('Role', () => {
expect(createMany).toHaveBeenCalledTimes(1);
expect(createMany).toHaveBeenCalledWith([
{ action: 'action-0', conditions: [], properties: {}, role: 1, subject: null },
{ action: 'action-1', conditions: [], properties: {}, role: 1, subject: null },
{ action: 'action-2', conditions: [], properties: {}, role: 1, subject: null },
{ action: 'action-3', conditions: [], properties: {}, role: 1, subject: null },
{ action: 'action-4', conditions: ['cond'], properties: {}, role: 1, subject: null },
{
action: 'action-0',
actionParameters: {},
conditions: [],
properties: {},
role: 1,
subject: null,
},
{
action: 'action-1',
actionParameters: {},
conditions: [],
properties: {},
role: 1,
subject: null,
},
{
action: 'action-2',
actionParameters: {},
conditions: [],
properties: {},
role: 1,
subject: null,
},
{
action: 'action-3',
actionParameters: {},
conditions: [],
properties: {},
role: 1,
subject: null,
},
{
action: 'action-4',
actionParameters: {},
conditions: ['cond'],
properties: {},
role: 1,
subject: null,
},
]);
});
});
@ -775,6 +821,7 @@ describe('Role', () => {
const permissions = [
{
action: 'someAction',
actionParameters: {},
conditions: [],
properties: { fields: null },
subject: null,

View File

@ -24,7 +24,7 @@ const ACTIONS = {
const sanitizeRole = omit(['users', 'permissions']);
const COMPARABLE_FIELDS = ['conditions', 'properties', 'subject', 'action'];
const COMPARABLE_FIELDS = ['conditions', 'properties', 'subject', 'action', 'actionParameters'];
const pickComparableFields = pick(COMPARABLE_FIELDS);
const jsonClean = (data) => JSON.parse(JSON.stringify(data));
@ -368,17 +368,20 @@ const assignPermissions = async (roleId, permissions = []) => {
return permissionsToReturn;
};
const addPermissions = async (roleId, permissions) => {
const { conditionProvider, createMany } = getService('permission');
const { sanitizeConditions } = permissionDomain;
const permissionsWithRole = permissions
.map(set('role', roleId))
.map(sanitizeConditions(conditionProvider));
.map(sanitizeConditions(conditionProvider))
.map(permissionDomain.create);
return createMany(permissionsWithRole);
};
const isContentTypeAction = (action) => action.section === CONTENT_TYPE_SECTION;
/**

View File

@ -17,7 +17,7 @@ const registerProviderActionSchema = yup
(v) => `${v.path}: The id can only contain lowercase letters, dots and hyphens.`
)
.required(),
section: yup.string().oneOf(['contentTypes', 'plugins', 'settings']).required(),
section: yup.string().oneOf(['contentTypes', 'plugins', 'settings', 'internal']).required(),
pluginName: yup.mixed().when('section', {
is: 'plugins',
then: validators.isAPluginName.required(),

View File

@ -38,6 +38,7 @@
"@casl/ability": "5.4.4",
"@strapi/utils": "4.12.1",
"lodash": "4.17.21",
"qs": "6.11.1",
"sift": "16.0.1"
},
"devDependencies": {

View File

@ -4,8 +4,14 @@ const PERMISSION_FIELDS = ['action', 'subject', 'properties', 'conditions'] as c
const sanitizePermissionFields = _.pick(PERMISSION_FIELDS);
export interface ParametrizedAction {
name: string;
params: Record<string, unknown>;
}
export interface Permission {
action: string;
actionParameters?: Record<string, unknown>;
subject?: string | object | null;
properties?: object;
conditions?: string[];

View File

@ -1,9 +1,16 @@
import * as sift from 'sift';
import qs from 'qs';
import { AbilityBuilder, Ability, Subject } from '@casl/ability';
import { pick, isNil, isObject } from 'lodash/fp';
export interface ParametrizedAction {
name: string;
params: Record<string, unknown>;
}
export interface PermissionRule {
action: string;
action: string | ParametrizedAction;
subject?: Subject | null;
properties?: {
fields?: string[];
@ -13,6 +20,7 @@ export interface PermissionRule {
export interface CustomAbilityBuilder {
can(permission: PermissionRule): ReturnType<AbilityBuilder<Ability>['can']>;
buildParametrizedAction: (parametrizedAction: ParametrizedAction) => string;
build(): Ability;
}
@ -37,6 +45,10 @@ const conditionsMatcher = (conditions: unknown) => {
return sift.createQueryTester(conditions, { operations });
};
const buildParametrizedAction = ({ name, params }: ParametrizedAction) => {
return `${name}?${qs.stringify(params)}`;
};
/**
* Casl Ability Builder.
*/
@ -48,16 +60,36 @@ export const caslAbilityBuilder = (): CustomAbilityBuilder => {
const { action, subject, properties = {}, condition } = permission;
const { fields } = properties;
const caslAction = (typeof action === "string") ? action : buildParametrizedAction(action);
return can(
action,
caslAction,
isNil(subject) ? 'all' : subject,
fields,
isObject(condition) ? condition : undefined
);
},
buildParametrizedAction({ name, params }: ParametrizedAction) {
return `${name}?${qs.stringify(params)}`;
},
build() {
return build({ conditionsMatcher });
const ability = build({ conditionsMatcher });
function decorateCan(originalCan: Ability['can']) {
return function (...args: Parameters<Ability['can']>) {
const [action, ...rest] = args;
const caslAction = (typeof action === "string") ? action : buildParametrizedAction(action);
// Call the original `can` method
return originalCan.apply(ability, [caslAction, ...rest]);
};
}
ability.can = decorateCan(ability.can);
return ability;
},
...rest,

View File

@ -1,4 +1,5 @@
import _ from 'lodash/fp';
import qs from 'qs';
import { Ability } from '@casl/ability';
import { providerFactory } from '@strapi/utils';
@ -93,7 +94,19 @@ const newEngine = (params: EngineParams): Engine => {
await state.hooks['before-evaluate.permission'].call(createBeforeEvaluateContext(permission));
const { action, subject, properties, conditions = [] } = permission;
const {
action: actionName,
subject,
properties,
conditions = [],
actionParameters = {},
} = permission;
let action = actionName;
if (actionParameters && Object.keys(actionParameters).length > 0) {
action = `${actionName}?${qs.stringify(actionParameters)}`;
}
if (conditions.length === 0) {
return register({ action, subject, properties });

View File

@ -39,7 +39,10 @@ const TableBody = ({ sortedRoles, canDelete, permissions, setRoleToDelete, onDel
<Td width="30%">
<Typography>
{formatMessage(
{ id: 'Roles.RoleRow.user-count', defaultMessage: '{number, plural, =0 {# user} one {# user} other {# users}}' },
{
id: 'Roles.RoleRow.user-count',
defaultMessage: '{number, plural, =0 {# user} one {# user} other {# users}}',
},
{ number: role.nb_users }
)}
</Typography>

View File

@ -7903,6 +7903,7 @@ __metadata:
"@strapi/utils": 4.12.1
eslint-config-custom: 4.12.1
lodash: 4.17.21
qs: 6.11.1
sift: 16.0.1
tsconfig: 4.12.1
languageName: unknown