diff --git a/packages/strapi-admin/services/permission/permissions-manager.js b/packages/strapi-admin/services/permission/permissions-manager.js index 790e739d99..187101e994 100644 --- a/packages/strapi-admin/services/permission/permissions-manager.js +++ b/packages/strapi-admin/services/permission/permissions-manager.js @@ -3,7 +3,7 @@ const _ = require('lodash'); const { subject: asSubject } = require('@casl/ability'); const { permittedFieldsOf, rulesToQuery } = require('@casl/ability/extra'); -const { VALID_REST_OPERATORS } = require('strapi-utils'); +const { VALID_REST_OPERATORS, sanitizeEntity } = require('strapi-utils'); const ops = { common: VALID_REST_OPERATORS, @@ -20,16 +20,12 @@ module.exports = (ability, action, model) => ({ return buildStrapiQuery(buildCaslQuery(ability, action, model)); }, - queryFrom(otherQuery = {}) { - return { ...otherQuery, _where: { ...otherQuery._where, ...this.query } }; - }, - toSubject(target, subjectType = model) { return asSubject(subjectType, target); }, pickPermittedFieldsOf(data, options = {}) { - return this.sanitize(data, { ...options, isInput: true }); + return this.sanitize(data, { ...options, isOutput: false }); }, sanitize(data, options = {}) { @@ -37,7 +33,7 @@ module.exports = (ability, action, model) => ({ subject = this.toSubject(data), action: actionOverride = action, withPrivate = true, - isInput = false, + isOutput = true, } = options; if (_.isArray(data)) { @@ -46,105 +42,11 @@ module.exports = (ability, action, model) => ({ const permittedFields = permittedFieldsOf(ability, actionOverride, subject); - const sanitizeDeep = ( - data, - { modelName, modelPlugin, withPrivate, fields, isInput = false } - ) => { - if (typeof data !== 'object' || _.isNil(data)) return data; - - const plainData = typeof data.toJSON === 'function' ? data.toJSON() : data; - if (typeof plainData !== 'object') return plainData; - - const modelDef = strapi.getModel(modelName, modelPlugin); - - if (!modelDef) return null; - - const { attributes, options, primaryKey } = modelDef; - - const timestamps = options.timestamps || []; - const creatorFields = ['created_by', 'updated_by']; - const componentFields = ['__component']; - - const inputFields = [primaryKey, componentFields]; - const outputFields = [primaryKey, timestamps, creatorFields, componentFields]; - - const allowedFields = _.concat(fields, ...(isInput ? inputFields : outputFields)); - - const filterFields = (fields, key) => - fields - .filter(field => field.startsWith(`${key}.`)) - .map(field => field.replace(`${key}.`, '')); - - return _.reduce( - plainData, - (acc, value, key) => { - const attribute = attributes[key]; - const isAllowedField = !fields || allowedFields.includes(key); - - // Always remove password fields from entities in output mode - if (attribute && attribute.type === 'password' && !isInput) { - return acc; - } - - // Removes private fields if needed - if (attribute && attribute.private === true && !withPrivate && !isInput) { - return acc; - } - - const relation = - attribute && (attribute.model || attribute.collection || attribute.component); - // Attribute is a relation - if (relation && value !== null) { - const filteredFields = filterFields(allowedFields, key); - - const isAllowed = allowedFields.includes(key) || filteredFields.length > 0; - if (!isAllowed) { - return acc; - } - - const nextFields = allowedFields.includes(key) ? null : filteredFields; - - const opts = { - modelName: relation, - modelPlugin: attribute.plugin, - withPrivate, - fields: nextFields, - isInput, - }; - - const val = Array.isArray(value) - ? value.map(entity => sanitizeDeep(entity, opts)) - : sanitizeDeep(value, opts); - - return { ...acc, [key]: val }; - } - - // Attribute is a dynamic zone - if (attribute && attribute.components && value !== null && allowedFields.includes(key)) { - return { - ...acc, - [key]: value.map(data => - sanitizeDeep(data, { modelName: data.__component, withPrivate, isInput }) - ), - }; - } - - // Add the key & value if we have the permission - if (isAllowedField) { - return { ...acc, [key]: value }; - } - - return acc; - }, - {} - ); - }; - - return sanitizeDeep(data, { - modelName: model, + return sanitizeEntity(data, { + model: strapi.getModel(model), + includeFields: _.isEmpty(permittedFields) ? null : permittedFields, withPrivate, - fields: _.isEmpty(permittedFields) ? null : permittedFields, - isInput, + isOutput, }); }, }); diff --git a/packages/strapi-utils/lib/sanitize-entity.js b/packages/strapi-utils/lib/sanitize-entity.js index f4d5bcc620..525e38f30f 100644 --- a/packages/strapi-utils/lib/sanitize-entity.js +++ b/packages/strapi-utils/lib/sanitize-entity.js @@ -1,44 +1,124 @@ 'use strict'; -module.exports = function sanitizeEntity(data, { model, withPrivate = false }) { - if (typeof data !== 'object' || data == null) return data; +const _ = require('lodash'); - let plainData = typeof data.toJSON === 'function' ? data.toJSON() : data; +const sanitizeEntity = (dataSource, options) => { + const { model, withPrivate = false, isOutput = true, includeFields = null } = options; - if (typeof plainData !== 'object') return plainData; + if (typeof dataSource !== 'object' || _.isNil(dataSource)) { + return dataSource; + } - const attributes = model.attributes; - return Object.keys(plainData).reduce((acc, key) => { + const data = parseOriginalData(dataSource); + + if (typeof data !== 'object') { + return data; + } + + if (_.isNil(model)) { + return null; + } + + const { attributes } = model; + const allowedFields = getAllowedFields({ includeFields, model, isOutput }); + + const reducerFn = (acc, value, key) => { const attribute = attributes[key]; - if (attribute && attribute.private === true && withPrivate !== true) { + const allowedFieldsHasKey = allowedFields.includes(key); + + if (shouldRemoveAttribute(attribute, { withPrivate, isOutput })) { return acc; } - if (attribute && (attribute.model || attribute.collection || attribute.type === 'component')) { - const targetName = attribute.model || attribute.collection || attribute.component; - - const targetModel = strapi.getModel(targetName, attribute.plugin); - - if (targetModel && plainData[key] !== null) { - acc[key] = Array.isArray(plainData[key]) - ? plainData[key].map(entity => - sanitizeEntity(entity, { model: targetModel, withPrivate }) - ) - : sanitizeEntity(plainData[key], { model: targetModel, withPrivate }); + // Relations + const relation = attribute && (attribute.model || attribute.collection || attribute.component); + if (relation && value !== null) { + const [nextFields, isAllowed] = getNextFields(allowedFields, key, { allowedFieldsHasKey }); + if (!isAllowed) { return acc; } + + const nextOptions = { + model: strapi.getModel(relation, attribute.plugin), + withPrivate, + isOutput, + includeFields: nextFields, + }; + + const nextVal = Array.isArray(value) + ? value.map(elem => sanitizeEntity(elem, nextOptions)) + : sanitizeEntity(value, nextOptions); + + return { ...acc, [key]: nextVal }; } - if (attribute && attribute.components && plainData[key] !== null) { - acc[key] = plainData[key].map(data => { - const model = strapi.getModel(data.__component); - return model ? sanitizeEntity(data, { model, withPrivate }) : null; - }); - return acc; + // Dynamic zones + if (attribute && attribute.components && value !== null && allowedFieldsHasKey) { + const nextVal = value.map(elem => + sanitizeEntity(elem, { + model: strapi.getModel(elem.__component), + withPrivate, + isOutput, + }) + ); + return { ...acc, [key]: nextVal }; + } + + // Other fields + const isAllowedField = !includeFields || allowedFieldsHasKey; + if (isAllowedField) { + return { ...acc, [key]: value }; } - acc[key] = plainData[key]; return acc; - }, {}); + }; + + return _.reduce(data, reducerFn, {}); }; + +const parseOriginalData = data => (_.isFunction(data.toJSON) ? data.toJSON() : data); + +const getAllowedFields = ({ includeFields, model, isOutput }) => { + const { options, primaryKey } = model; + + const timestamps = options.timestamps || []; + const creatorFields = ['created_by', 'updated_by']; + const componentFields = ['__component']; + + return _.concat( + includeFields || [], + ...(isOutput + ? [primaryKey, componentFields, timestamps, creatorFields] + : [primaryKey, componentFields]) + ); +}; + +const getNextFields = (fields, key, { allowedFieldsHasKey }) => { + const searchStr = `${key}.`; + + const transformedFields = (fields || []) + .filter(field => field.startsWith(searchStr)) + .map(field => field.replace(searchStr, '')); + + const isAllowed = allowedFieldsHasKey || transformedFields.length > 0; + const nextFields = allowedFieldsHasKey ? null : transformedFields; + + return [nextFields, isAllowed]; +}; + +const shouldRemoveAttribute = (attribute, { withPrivate, isOutput }) => { + if (_.isNil(attribute)) { + return false; + } + + const isPassword = attribute.type === 'password'; + const isPrivate = attribute.private === true; + + const shouldRemovePassword = isOutput; + const shouldRemovePrivate = !withPrivate && isOutput; + + return !!((isPassword && shouldRemovePassword) || (isPrivate && shouldRemovePrivate)); +}; + +module.exports = sanitizeEntity;