UNPKG

scim-patch-with-remove

Version:
300 lines 14 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.scimPatch = exports.patchBodyValidation = exports.PATCH_OPERATION_SCHEMA = exports.UnsupportedBlueprintEntities = exports.DeepArrayRemovalNotSupported = exports.NoTarget = exports.InvalidScimPatchRequest = exports.NoPathInScimPatchOp = exports.InvalidScimPatchOp = exports.InvalidScimPatch = exports.ScimError = void 0; const scimErrors_1 = require("./errors/scimErrors"); Object.defineProperty(exports, "ScimError", { enumerable: true, get: function () { return scimErrors_1.ScimError; } }); Object.defineProperty(exports, "InvalidScimPatch", { enumerable: true, get: function () { return scimErrors_1.InvalidScimPatch; } }); Object.defineProperty(exports, "InvalidScimPatchOp", { enumerable: true, get: function () { return scimErrors_1.InvalidScimPatchOp; } }); Object.defineProperty(exports, "NoPathInScimPatchOp", { enumerable: true, get: function () { return scimErrors_1.NoPathInScimPatchOp; } }); Object.defineProperty(exports, "InvalidScimPatchRequest", { enumerable: true, get: function () { return scimErrors_1.InvalidScimPatchRequest; } }); Object.defineProperty(exports, "NoTarget", { enumerable: true, get: function () { return scimErrors_1.NoTarget; } }); Object.defineProperty(exports, "DeepArrayRemovalNotSupported", { enumerable: true, get: function () { return scimErrors_1.DeepArrayRemovalNotSupported; } }); Object.defineProperty(exports, "UnsupportedBlueprintEntities", { enumerable: true, get: function () { return scimErrors_1.UnsupportedBlueprintEntities; } }); const scim2_parse_filter_1 = require("scim2-parse-filter"); const deepEqual = require("fast-deep-equal"); /* * This file implement the SCIM PATCH specification. * RFC : https://tools.ietf.org/html/rfc7644#section-3.5.2 * It allow to apply some patch on an existing SCIM resource. */ // Regex to check if this is search into array request. const IS_ARRAY_SEARCH = /(\[|\])/; // Regex to extract key and search request (ex: emails[primary eq true). const ARRAY_SEARCH = /^(.+)\[(.+)\]$/; // Split path on all periods except e.g. "2.0" const SPLIT_PERIOD = /(?!\d)\.(?!\d)/g; // Valid patch operation, value needs to be in lowercase here. const AUTHORIZED_OPERATION = ['remove', 'add', 'replace']; exports.PATCH_OPERATION_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:PatchOp'; /* * PatchBodyValidation validate if the request body of the SCIM Patch is valid. * If the body is not valid the function throw an error. * @param body data from the patch request. * @throws {InvalidScimPatchRequest} if one operation is not valid. * @throws {NoPathInScimPatchOp} if one operation is a remove with no path. */ function patchBodyValidation(body) { if (!body.schemas || !body.schemas.includes(exports.PATCH_OPERATION_SCHEMA)) throw new scimErrors_1.InvalidScimPatchRequest('Missing schemas.'); if (!body.Operations || body.Operations.length <= 0) throw new scimErrors_1.InvalidScimPatchRequest('Missing operations.'); body.Operations.forEach(validatePatchOperation); } exports.patchBodyValidation = patchBodyValidation; /* * This method apply patch operations on a SCIM Resource. * @param scimResource The initial resource * @param patchOperations An array of SCIM patch operations we want to apply on the scimResource object. * @return the scimResource patched. * @throws {InvalidScimPatchOp} if the patch could not happen. */ function scimPatch(scimResource, patchOperations) { return patchOperations.reduce((patchedResource, patch) => { switch (patch.op) { case 'remove': case 'Remove': return applyRemoveOperation(patchedResource, patch); case 'add': case 'Add': case 'replace': case 'Replace': return applyAddOrReplaceOperation(patchedResource, patch); default: throw new scimErrors_1.InvalidScimPatchRequest(`Operator is invalid for SCIM patch request. ${patch}`); } }, scimResource); } exports.scimPatch = scimPatch; /* * validateOperation is validating that the SCIM Patch Operation follow the RFC. * If not, the function throw an Error. * @param operation The SCIM operation we want to check. * @throws {InvalidScimPatchRequest} if the operation is not valid. * @throws {NoPathInScimPatchOp} if the operation is a remove with no path. */ function validatePatchOperation(operation) { if (!operation.op || Array.isArray(operation.op) || !isValidOperation(operation.op)) throw new scimErrors_1.InvalidScimPatchRequest(`Invalid op "${operation.op}" in the request.`); if (operation.op === 'remove' && !operation.path) throw new scimErrors_1.NoPathInScimPatchOp(); if (operation.op === 'add' && !('value' in operation)) throw new scimErrors_1.InvalidScimPatchRequest(`The operation ${operation.op} MUST contain a "value" member whose content specifies the value to be added`); if (operation.path && typeof operation.path !== 'string') throw new scimErrors_1.InvalidScimPatchRequest('Path is supposed to be a string'); } function applyRemoveOperation(scimResource, patch) { // We manipulate the object directly without knowing his property, that's why we use any. let resource = scimResource; validatePatchOperation(patch); // Path is supposed to be set, there are a validation in the validateOperation function. const paths = patch.path.split(SPLIT_PERIOD); const value = patch.value; resource = navigate(resource, paths); // Dealing with the last element of the path. const lastSubPath = paths[paths.length - 1]; if (!IS_ARRAY_SEARCH.test(lastSubPath)) { // This is a mono valued property if (!value) { // No value in the remove operation, we delete it. delete resource[lastSubPath]; return scimResource; } // Value in the remove operation, we remove the children by value. resource[lastSubPath] = filterWithArray(resource[lastSubPath], value); return scimResource; } // The last element is an Array request. const { attrName, valuePath, array } = extractArray(lastSubPath, resource); // We keep only items who don't match the query if supplied. resource[attrName] = array.filter((e) => !filterWithQuery(array, valuePath).includes(e)); // If the complex multi-valued attribute has no remaining records, the attribute SHALL be considered unassigned. if (resource[attrName].length === 0) delete resource[attrName]; return scimResource; } function applyAddOrReplaceOperation(scimResource, patch) { // We manipulate the object directly without knowing his property, that's why we use any. let resource = scimResource; validatePatchOperation(patch); if (!patch.path) return addOrReplaceAttribute(scimResource, patch); // We navigate till the second to last of the path. const paths = patch.path.split(SPLIT_PERIOD); resource = navigate(resource, paths); const lastSubPath = paths[paths.length - 1]; if (!IS_ARRAY_SEARCH.test(lastSubPath)) { if (resource === undefined) { throw new scimErrors_1.NoTarget(patch.value); } resource[lastSubPath] = addOrReplaceAttribute(resource[lastSubPath], patch); return scimResource; } // The last element is an Array request. const { valuePath, array } = extractArray(lastSubPath, resource); // Get the list of items who are successful for the search query. const matchFilter = filterWithQuery(array, valuePath); // If the target location specifies a complex attribute, a set of sub-attributes SHALL be specified in the "value" // parameter, which replaces any existing values or adds where an attribute did not previously exist. const isReplace = patch.op.toLowerCase() === 'replace'; if (isReplace && matchFilter.length === 0) { array.push(patch.value); return scimResource; } // We are sure to find an index because matchFilter comes from array. const index = array.findIndex(item => matchFilter.includes(item)); array[index] = addOrReplaceAttribute(array[index], patch); return scimResource; } /** * extractArray extract the valuePath (ex: email[primary eq true]) of a subPath * @param subPath The key we want to extract. * @param schema The object which is supposed to contains the array. * @return an array with the array name and the filter path. */ function extractArray(subPath, schema) { // We extract the key of the table and what is inside []. const matchRequest = subPath.match(ARRAY_SEARCH); if (!matchRequest) throw new scimErrors_1.InvalidScimPatchOp(`This part of the path ${subPath} is invalid for SCIM patch request.`); const [, attrName, valuePath] = matchRequest; const element = schema[attrName]; if (!Array.isArray(element)) throw new scimErrors_1.InvalidScimPatchOp('Impossible to search on a mono valued attribute.'); return new ScimSearchQuery(attrName, valuePath, element); } /** * navigate allow to get the sub object who want to edit with the patch operation. * @param inputSchema the initial ScimResource * @param paths an Array who contains the path of the sub object * @return the parent object of the element we want to edit */ function navigate(inputSchema, paths) { let schema = inputSchema; for (let i = 0; i < paths.length - 1; i++) { const subPath = paths[i]; // We check if the element is an array with query (ex: emails[primary eq true). if (IS_ARRAY_SEARCH.test(subPath)) { const { valuePath, array } = extractArray(subPath, schema); try { // Get the item who is successful for the search query. const matchFilter = filterWithQuery(array, valuePath); // We are sure to find an index because matchFilter comes from array. const index = array.findIndex(item => matchFilter.includes(item)); schema = array[index]; } catch (error) { throw new scimErrors_1.InvalidScimPatchOp(error); } } else { // The element is not an array. if (!schema[subPath]) schema[subPath] = {}; schema = schema[subPath]; } } return schema; } /** * Add or Replace a property in the ScimResource * @param property The property we want to replace * @param patch The patch operation * @return the patched property */ function addOrReplaceAttribute(property, patch) { if (Array.isArray(property)) { if (Array.isArray(patch.value)) { // if we're adding an array, we need to remove duplicated values from existing array if (patch.op.toLowerCase() === "add") { const valuesToAdd = patch.value.filter(item => !property.includes(item)); return property.concat(valuesToAdd); } // else this is a replace operation return patch.value; } const a = property; if (!a.includes(patch.value)) a.push(patch.value); return a; } if (typeof property === 'object') { if (typeof patch.value !== 'object') { if (patch.op === 'add') throw new scimErrors_1.InvalidScimPatchOp('Invalid patch query.'); return patch.value; } return Object.assign(Object.assign({}, property), patch.value); } // If the target location specifies a single-valued attribute, the existing value is replaced. return patch.value; } /** * Return the items in the array who match the filter. * @param arr the collection where we are searching. * @param querySearch the search request. * @return an array who contains the search results. */ function filterWithQuery(arr, querySearch) { try { return arr.filter(scim2_parse_filter_1.filter(scim2_parse_filter_1.parse(querySearch))); } catch (error) { throw new scimErrors_1.InvalidScimPatchOp(error); } } /** * Return the array without items supplied in . * @param arr the collection where we are searching. * @param itemsToRemove array with items to remove from original. * @return an array which contains the search results. */ function filterWithArray(arr, itemsToRemove) { if (!Array.isArray(arr)) throw new scimErrors_1.UnsupportedBlueprintEntities(); if (isObject(itemsToRemove)) { let shouldResume = true; while (shouldResume) { const index = arr.findIndex((mainItem) => deepEqual(itemsToRemove, mainItem)); dropItemFromArray(arr, index); if (index === -1) { shouldResume = false; } } return arr; } itemsToRemove.forEach((itemToRemove) => { if (Array.isArray(itemToRemove)) throw new scimErrors_1.DeepArrayRemovalNotSupported(); let shouldResume = true; while (shouldResume) { const index = arr.findIndex((mainItem) => deepEqual(itemToRemove, mainItem)); dropItemFromArray(arr, index); if (index === -1) { shouldResume = false; } } }); return arr; } function dropItemFromArray(arr, index) { if (index === -1) return; const mainArrMaxIndex = arr.length - 1; [arr[index], arr[mainArrMaxIndex]] = [arr[mainArrMaxIndex], arr[index]]; arr.pop(); return; } function isObject(object) { return object != null && typeof object === 'object' && !Array.isArray(object); } function isValidOperation(operation) { return AUTHORIZED_OPERATION.includes(operation.toLowerCase()); } class ScimSearchQuery { constructor(attrName, valuePath, array) { this.attrName = attrName; this.valuePath = valuePath; this.array = array; } } //# sourceMappingURL=scimPatch.js.map