scim-patch-with-remove
Version:
SCIM Patch operation (rfc7644).
381 lines (325 loc) • 13.5 kB
text/typescript
import {
ScimError,
InvalidScimPatch,
InvalidScimPatchOp,
NoPathInScimPatchOp,
InvalidScimPatchRequest,
NoTarget,
DeepArrayRemovalNotSupported,
UnsupportedBlueprintEntities
} from './errors/scimErrors';
import {
ScimPatchSchema,
ScimId,
ScimSchema,
ScimPatchOperation,
ScimPatchRemoveOperation,
ScimPatchAddReplaceOperation,
ScimPatch,
ScimResource,
ScimMeta
} from './types/types';
import {parse, filter} from 'scim2-parse-filter';
import deepEqual = require('fast-deep-equal');
/*
* Export types
*/
export {
ScimPatchSchema,
ScimId,
ScimSchema,
ScimPatchOperation,
ScimPatchRemoveOperation,
ScimPatchAddReplaceOperation,
ScimPatch,
ScimResource,
ScimMeta,
ScimError,
InvalidScimPatch,
InvalidScimPatchOp,
NoPathInScimPatchOp,
InvalidScimPatchRequest,
NoTarget,
DeepArrayRemovalNotSupported,
UnsupportedBlueprintEntities
};
/*
* 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: RegExp = /^(.+)\[(.+)\]$/;
// 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'];
export const 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.
*/
export function patchBodyValidation(body: ScimPatch): void {
if (!body.schemas || !body.schemas.includes(PATCH_OPERATION_SCHEMA))
throw new InvalidScimPatchRequest('Missing schemas.');
if (!body.Operations || body.Operations.length <= 0)
throw new InvalidScimPatchRequest('Missing operations.');
body.Operations.forEach(validatePatchOperation);
}
/*
* 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.
*/
export function scimPatch<T extends ScimResource>(scimResource: T, patchOperations: Array<ScimPatchOperation>): T {
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 InvalidScimPatchRequest(`Operator is invalid for SCIM patch request. ${patch}`);
}
}, scimResource);
}
/*
* 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: ScimPatchOperation): void {
if (!operation.op || Array.isArray(operation.op) || !isValidOperation(operation.op))
throw new InvalidScimPatchRequest(`Invalid op "${operation.op}" in the request.`);
if (operation.op === 'remove' && !operation.path)
throw new NoPathInScimPatchOp();
if (operation.op === 'add' && !('value' in operation))
throw new 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 InvalidScimPatchRequest('Path is supposed to be a string');
}
function applyRemoveOperation<T extends ScimResource>(scimResource: T, patch: ScimPatchRemoveOperation): T {
// We manipulate the object directly without knowing his property, that's why we use any.
let resource: Record<string, any> = 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: any) => !filterWithQuery<any>(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<T extends ScimResource>(scimResource: T, patch: ScimPatchAddReplaceOperation): T {
// We manipulate the object directly without knowing his property, that's why we use any.
let resource: Record<string, any> = 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 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<any>(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: string, schema: any): ScimSearchQuery {
// We extract the key of the table and what is inside [].
const matchRequest = subPath.match(ARRAY_SEARCH);
if (!matchRequest)
throw new 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 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: any, paths: string[]): any {
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<any>(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 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: any, patch: ScimPatchAddReplaceOperation): any {
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 InvalidScimPatchOp('Invalid patch query.');
return patch.value;
}
return {
...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<T>(arr: Array<T>, querySearch: string): Array<T> {
try {
return arr.filter(filter(parse(querySearch)));
} catch (error) {
throw new 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<T>(arr: T[], itemsToRemove: T[] | Record<string, any>): T[] {
if (!Array.isArray(arr)) throw new 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 as T[]).forEach((itemToRemove) => {
if (Array.isArray(itemToRemove)) throw new 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<T>(arr: T[], index: number) {
if (index === -1) return;
const mainArrMaxIndex = arr.length - 1;
[arr[index], arr[mainArrMaxIndex]] = [arr[mainArrMaxIndex], arr[index]]
arr.pop()
return;
}
function isObject(object: Record<string, any>): boolean {
return object != null && typeof object === 'object' && !Array.isArray(object);
}
function isValidOperation(operation: string): boolean {
return AUTHORIZED_OPERATION.includes(operation.toLowerCase());
}
class ScimSearchQuery {
constructor(
readonly attrName: string,
readonly valuePath: string,
readonly array: Array<any>
) {
}
}