feat: partially update rbac permissions

This commit is contained in:
Marc-Roig 2023-08-18 15:35:25 +02:00
parent 04fc8a0875
commit e8105938c7
No known key found for this signature in database
GPG Key ID: FB4E2C43A0BEE249
5 changed files with 200 additions and 104 deletions

View File

@ -146,7 +146,7 @@ module.exports = {
return ctx.notFound('role.notFound'); return ctx.notFound('role.notFound');
} }
const permissions = await roleService.assignPermissions(role.id, input.permissions); const permissions = await roleService.updatePermissions(role.id, input.permissions);
const sanitizedPermissions = permissions.map(permissionService.sanitizePermission); const sanitizedPermissions = permissions.map(permissionService.sanitizePermission);

View File

@ -182,6 +182,7 @@ const cleanPermissionsInDatabase = async () => {
module.exports = { module.exports = {
createMany, createMany,
update,
findMany, findMany,
deleteByRolesIds, deleteByRolesIds,
deleteByIds, deleteByIds,

View File

@ -1,11 +1,22 @@
'use strict'; 'use strict';
const _ = require('lodash'); const _ = require('lodash');
const { set, omit, pick, prop, isArray, differenceWith, differenceBy } = require('lodash/fp'); const {
set,
omit,
pick,
prop,
isArray,
differenceWith,
differenceBy,
flow,
map,
} = require('lodash/fp');
const deepEqual = require('fast-deep-equal'); const deepEqual = require('fast-deep-equal');
const { const {
generateTimestampCode, generateTimestampCode,
stringIncludes, stringIncludes,
mapAsync,
hooks: { createAsyncSeriesWaterfallHook }, hooks: { createAsyncSeriesWaterfallHook },
} = require('@strapi/utils'); } = require('@strapi/utils');
const { ApplicationError } = require('@strapi/utils').errors; const { ApplicationError } = require('@strapi/utils').errors;
@ -315,6 +326,78 @@ const displayWarningIfNoSuperAdmin = async () => {
} }
}; };
/**
* Partially update a role permissions in database.
* Permissions will use the format of:
* {
* connect: [permission1, permission2],
* disconnect: [permission3, permission4]
* }
*
* @param {*} roleId
* @param {*} permissions
*/
const partialAssignPermissions = async (roleId, permissions = {}) => {
const superAdmin = await getService('role').getSuperAdmin();
const isSuperAdmin = superAdmin && superAdmin.id === roleId;
const permissionsWithRole = flow(
map(set('role', roleId)), // Assign role
map(permissionDomain.create) // Map permission to domain
);
const connect = permissionsWithRole(permissions.connect) || [];
const disconnect = permissionsWithRole(permissions.disconnect) || [];
await validatePermissionsExist(connect);
const permissionsToCreate = connect.filter((permission) => !permission.id);
const permissionsToUpdate = connect.filter((permission) => permission.id);
const permissionsToDelete = disconnect;
const existingPermissions = await getService('permission').findMany({
where: { role: { id: roleId } },
populate: ['role'],
});
// Find permissions that do not exist in db
const invalidUpdatePermissions = differenceBy('id', permissionsToUpdate, existingPermissions);
const invalidDeletePermissions = differenceBy('id', permissionsToDelete, existingPermissions);
if (invalidUpdatePermissions.length !== 0) {
throw new ApplicationError('Some permissions to update do not exist');
}
if (invalidDeletePermissions.length !== 0) {
throw new ApplicationError('Some permissions to delete do not exist');
}
// Array of final permissions to return
const permissionsToReturn = differenceBy('id', existingPermissions, [
...permissionsToDelete,
...permissionsToUpdate,
]);
if (permissionsToDelete.length > 0) {
await getService('permission').deleteByIds(permissionsToDelete.map(prop('id')));
}
if (permissionsToCreate.length > 0) {
const newPermissions = await addPermissions(roleId, permissionsToCreate);
permissionsToReturn.push(...newPermissions);
}
if (permissionsToUpdate.length > 0) {
const updatedPermissions = await updatePermissions(roleId, permissionsToUpdate);
permissionsToReturn.push(...updatedPermissions);
}
if (!isSuperAdmin && (connect.length || disconnect.length)) {
await getService('metrics').sendDidUpdateRolePermissions();
}
return permissionsToReturn;
};
/** /**
* Assign permissions to a role * Assign permissions to a role
* @param {string|int} roleId - role ID * @param {string|int} roleId - role ID
@ -368,7 +451,6 @@ const assignPermissions = async (roleId, permissions = []) => {
return permissionsToReturn; return permissionsToReturn;
}; };
const addPermissions = async (roleId, permissions) => { const addPermissions = async (roleId, permissions) => {
const { conditionProvider, createMany } = getService('permission'); const { conditionProvider, createMany } = getService('permission');
const { sanitizeConditions } = permissionDomain; const { sanitizeConditions } = permissionDomain;
@ -381,6 +463,21 @@ const addPermissions = async (roleId, permissions) => {
return createMany(permissionsWithRole); return createMany(permissionsWithRole);
}; };
const updatePermissions = async (roleId, permissions) => {
// TODO: Update many
const { conditionProvider, update } = getService('permission');
const { sanitizeConditions } = permissionDomain;
const permissionsWithRole = permissions
.map(set('role', roleId))
.map(sanitizeConditions(conditionProvider))
.map(permissionDomain.create);
return mapAsync(permissionsWithRole, (permission) => {
const { id, ...attributes } = permission;
return update({ id }, attributes);
});
};
const isContentTypeAction = (action) => action.section === CONTENT_TYPE_SECTION; const isContentTypeAction = (action) => action.section === CONTENT_TYPE_SECTION;
@ -457,6 +554,7 @@ module.exports = {
displayWarningIfNoSuperAdmin, displayWarningIfNoSuperAdmin,
addPermissions, addPermissions,
hasSuperAdminRole, hasSuperAdminRole,
updatePermissions: partialAssignPermissions,
assignPermissions, assignPermissions,
resetSuperAdminPermissions, resetSuperAdminPermissions,
checkRolesIdForDeletion, checkRolesIdForDeletion,

View File

@ -89,112 +89,102 @@ const fieldsPropertyValidation = (action) =>
checkNilFields(action) checkNilFields(action)
); );
const permission = yup
.object()
.shape({
action: yup
.string()
.required()
.test('action-validity', 'action is not an existing permission action', function (actionId) {
// If the action field is Nil, ignore the test and let the required check handle the error
if (isNil(actionId)) {
return true;
}
return !!getActionFromProvider(actionId);
}),
subject: yup
.string()
.nullable()
.test('subject-validity', 'Invalid subject submitted', function (subject) {
const action = getActionFromProvider(this.options.parent.action);
if (!action) {
return true;
}
if (isNil(action.subjects)) {
return isNil(subject);
}
if (isArray(action.subjects)) {
return action.subjects.includes(subject);
}
return false;
}),
properties: yup
.object()
.test('properties-structure', 'Invalid property set at ${path}', function (properties) {
const action = getActionFromProvider(this.options.parent.action);
const hasNoProperties = isEmpty(properties) || isNil(properties);
if (!has('options.applyToProperties', action)) {
return hasNoProperties;
}
if (hasNoProperties) {
return true;
}
const { applyToProperties } = action.options;
if (!isArray(applyToProperties)) {
return false;
}
return Object.keys(properties).every((property) => applyToProperties.includes(property));
})
.test(
'fields-property',
'Invalid fields property at ${path}',
async function (properties = {}) {
const action = getActionFromProvider(this.options.parent.action);
if (!action || !properties) {
return true;
}
if (!actionDomain.appliesToProperty('fields', action)) {
return true;
}
try {
await fieldsPropertyValidation(action).validate(properties.fields, {
strict: true,
abortEarly: false,
});
return true;
} catch (e) {
// Propagate fieldsPropertyValidation error with updated path
throw this.createError({
message: e.message,
path: `${this.path}.fields`,
});
}
}
),
conditions: yup.array().of(yup.string()),
})
.noUnknown();
const updatePermissions = yup const updatePermissions = yup
.object() .object()
.shape({ .shape({
permissions: yup permissions: yup
.array() .array()
.required() .required()
.of( .of(permission)
yup
.object()
.shape({
action: yup
.string()
.required()
.test(
'action-validity',
'action is not an existing permission action',
function (actionId) {
// If the action field is Nil, ignore the test and let the required check handle the error
if (isNil(actionId)) {
return true;
}
return !!getActionFromProvider(actionId);
}
),
subject: yup
.string()
.nullable()
.test('subject-validity', 'Invalid subject submitted', function (subject) {
const action = getActionFromProvider(this.options.parent.action);
if (!action) {
return true;
}
if (isNil(action.subjects)) {
return isNil(subject);
}
if (isArray(action.subjects)) {
return action.subjects.includes(subject);
}
return false;
}),
properties: yup
.object()
.test(
'properties-structure',
'Invalid property set at ${path}',
function (properties) {
const action = getActionFromProvider(this.options.parent.action);
const hasNoProperties = isEmpty(properties) || isNil(properties);
if (!has('options.applyToProperties', action)) {
return hasNoProperties;
}
if (hasNoProperties) {
return true;
}
const { applyToProperties } = action.options;
if (!isArray(applyToProperties)) {
return false;
}
return Object.keys(properties).every((property) =>
applyToProperties.includes(property)
);
}
)
.test(
'fields-property',
'Invalid fields property at ${path}',
async function (properties = {}) {
const action = getActionFromProvider(this.options.parent.action);
if (!action || !properties) {
return true;
}
if (!actionDomain.appliesToProperty('fields', action)) {
return true;
}
try {
await fieldsPropertyValidation(action).validate(properties.fields, {
strict: true,
abortEarly: false,
});
return true;
} catch (e) {
// Propagate fieldsPropertyValidation error with updated path
throw this.createError({
message: e.message,
path: `${this.path}.fields`,
});
}
}
),
conditions: yup.array().of(yup.string()),
})
.noUnknown()
)
.test( .test(
'duplicated-permissions', 'duplicated-permissions',
'Some permissions are duplicated (same action and subject)', 'Some permissions are duplicated (same action and subject)',
@ -213,5 +203,6 @@ module.exports = {
roles, roles,
isAPluginName, isAPluginName,
arrayOfConditionNames, arrayOfConditionNames,
permission,
updatePermissions, updatePermissions,
}; };

View File

@ -4,6 +4,12 @@ const { yup, validateYupSchema } = require('@strapi/utils');
const { getService } = require('../utils'); const { getService } = require('../utils');
const validators = require('./common-validators'); const validators = require('./common-validators');
const updatePermissions = yup.object().shape({
// TODO: Add id
connect: yup.array().of(validators.permission),
disconnect: yup.array().of(yup.object().shape({ id: yup.strapiID().required() })),
});
const checkPermissionsSchema = yup.object().shape({ const checkPermissionsSchema = yup.object().shape({
permissions: yup.array().of( permissions: yup.array().of(
yup yup
@ -50,7 +56,7 @@ const actionsExistSchema = yup
// exports // exports
module.exports = { module.exports = {
validatedUpdatePermissionsInput: validateYupSchema(validators.updatePermissions), validatedUpdatePermissionsInput: validateYupSchema(updatePermissions),
validatePermissionsExist: validateYupSchema(actionsExistSchema), validatePermissionsExist: validateYupSchema(actionsExistSchema),
validateCheckPermissionsInput: validateYupSchema(checkPermissionsSchema), validateCheckPermissionsInput: validateYupSchema(checkPermissionsSchema),
}; };