UNPKG

scimmy

Version:

SCIMMY - SCIM m(ade eas)y

895 lines (802 loc) 70.5 kB
import Types from './types.js'; import Resources from './resources.js'; /** * HTTP response status codes specified by RFC7644§3.12 * @enum SCIMMY.Messages.ErrorResponse~ValidStatusCodes * @inner */ const validStatusCodes = [307, 308, 400, 401, 403, 404, 409, 412, 413, 500, 501]; /** * SCIM detail error keywords specified by RFC7644§3.12 * @enum SCIMMY.Messages.ErrorResponse~ValidScimTypes * @inner */ const validScimTypes = [ "uniqueness", "tooMany", "invalidFilter", "mutability", "invalidSyntax", "invalidPath", "noTarget", "invalidValue", "invalidVers", "sensitive" ]; // Map of valid scimType codes for each HTTP status code (where applicable) const validCodeTypes = {400: validScimTypes.slice(2), 409: ["uniqueness"], 413: ["tooMany"]}; /** * SCIM Error Message * @alias SCIMMY.Messages.ErrorResponse * @summary * * Formats exceptions to conform to the [HTTP Status and Error Response Handling](https://datatracker.ietf.org/doc/html/rfc7644#section-3.12) section of the SCIM protocol, ensuring HTTP status codes and scimType error detail keyword pairs are valid. * * When used to parse service provider responses, throws a new instance of `SCIMMY.Types.Error` with details sourced from the message. */ class ErrorResponse extends Error { /** @private */ static #id = "urn:ietf:params:scim:api:messages:2.0:Error"; /** * SCIM Error Message Schema ID * @type {"urn:ietf:params:scim:api:messages:2.0:Error"} */ static get id() { return this.#id; } /** * Details of the underlying cause of the error response * @typedef {Object} SCIMMY.Messages.ErrorResponse~CauseDetails * @property {SCIMMY.Messages.ErrorResponse~ValidStatusCodes} [status=500] - HTTP status code to be sent with the error * @property {SCIMMY.Messages.ErrorResponse~ValidScimTypes} [scimType] - the SCIM detail error keyword as per [RFC7644§3.12]{@link https://datatracker.ietf.org/doc/html/rfc7644#section-3.12} * @property {String} [detail] - a human-readable description of what caused the error to occur * @inner */ /** * Instantiate a new SCIM Error Message with relevant details * @param {typeof SCIMMY.Types.Error|SCIMMY.Messages.ErrorResponse~CauseDetails|Error} [ex={}] - the initiating exception to parse into a SCIM error message * @property {[typeof SCIMMY.Messages.ErrorResponse.id]} schemas - list exclusively containing the SCIM Error message schema ID * @property {SCIMMY.Messages.ErrorResponse~ValidStatusCodes} status - stringified HTTP status code to be sent with the error * @property {SCIMMY.Messages.ErrorResponse~ValidScimTypes} [scimType] - the SCIM detail error keyword as per [RFC7644§3.12]{@link https://datatracker.ietf.org/doc/html/rfc7644#section-3.12} * @property {String} [detail] - a human-readable description of what caused the error to occur */ constructor(ex = {}) { // Dereference parts of the exception const {schemas = [], status = 500, scimType, message, detail = message} = ex; const errorSuffix = "SCIM Error Message constructor"; super(message, {cause: ex}); // Rethrow SCIM Error messages when error message schema ID is present if (schemas.includes(ErrorResponse.#id)) throw new Types.Error(status, scimType, detail); // Validate the supplied parameters if (!validStatusCodes.includes(Number(status))) throw new TypeError(`Incompatible HTTP status code '${status}' supplied to ${errorSuffix}`); if (!!scimType && !validScimTypes.includes(scimType)) throw new TypeError(`Unknown detail error keyword '${scimType}' supplied to ${errorSuffix}`); if (!!scimType && !validCodeTypes[Number(status)]?.includes(scimType)) throw new TypeError(`HTTP status code '${Number(status)}' not valid for detail error keyword '${scimType}' in ${errorSuffix}`); // No exceptions thrown, assign the parameters to the instance this.schemas = [ErrorResponse.#id]; this.status = String(status); if (!!scimType) this.scimType = String(scimType); if (!!detail) this.detail = detail; } } /** * SCIM List Response Message * @alias SCIMMY.Messages.ListResponse * @template {SCIMMY.Types.Schema} [T=*] - type of schema instance that will be passed to handlers * @summary * * Formats supplied service provider resources as [ListResponse messages](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2), handling pagination and sort when required. * @description * The `ListResponse` class creates a SCIM [ListResponse message](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2) for a supplied set of resources, and automates pagination and sorting of the included set. * It is used internally by the `read()` method in each of {@link SCIMMY.Resources|SCIMMY's Resource classes} when requesting a list of resources matching a given query, * and in the {@link SCIMMY.Messages.SearchRequest#apply|`apply()`} instance method of the {@link SCIMMY.Messages.SearchRequest|SearchRequest} message class. * * > **Tip:** * > When using the {@link SCIMMY.Resources.User|`User`} and {@link SCIMMY.Resources.Group|`Group`} resource classes, their instance `read()` methods will wrap the set of matching query results in a `ListResponse` instance. * > This happens automatically when retrieving multiple resources, meaning their {@link SCIMMY.Types.Resource~EgressHandler|egress handlers} need only return the array of matching resources, not a `ListResponse` instance of their own. * * @example <caption>Basic usage with no constraints</caption> * // Retrieve the list of resources from somewhere, and pass it to the ListResponse constructor * const resources = [{id: "1", userName: "AdeleV"}, {id: "2", userName: "GradyA"}]; * const response = new SCIMMY.Messages.ListResponse(resources); * * @example <caption>Basic usage with specified total results</caption> * // Retrieve the list of resources from somewhere... * const resources = [{id: "1", userName: "AdeleV"}, {id: "2", userName: "GradyA"}]; * // ...and separately retrieve the total number of matching results * const totalResults = 1005; * * // Instantiate the list response with the resources array, specifying totalResults * const response = new SCIMMY.Messages.ListResponse(resources, {totalResults}); * * @example <caption>Advanced usage with manipulation of array length</caption> * // Retrieve the list of resources from somewhere... * const resources = [{id: "1", userName: "AdeleV"}, {id: "2", userName: "GradyA"}]; * // ...and set the length of the list to match the desired total * resources.length = 1005; * * // Instantiate the list response with the (mostly) sparse resources array * const response = new SCIMMY.Messages.ListResponse(resources, {itemsPerPage: 2}); */ class ListResponse { /** @private */ static #id = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; /** * SCIM List Response Message Schema ID * @type {"urn:ietf:params:scim:api:messages:2.0:ListResponse"} */ static get id() { return this.#id; } /** * ListResponse sort and pagination constraints * @typedef {Object} SCIMMY.Messages.ListResponse~ListConstraints * @property {String} [sortBy] - the attribute to sort results by, if any * @property {String} [sortOrder="ascending"] - the direction to sort results in, if sortBy is specified * @property {Number} [startIndex=1] - offset index that items start from * @property {Number} [count=20] - maximum number of items returned in this list response */ /** * Instantiate a new SCIM List Response Message with relevant details * @param {SCIMMY.Messages.ListResponse<T>|T[]} request - contents of the ListResponse message, or items to include in the list response * @param {SCIMMY.Messages.ListResponse~ListConstraints} [params] - parameters for the list response (i.e. sort details, start index, and items per page) * @param {String} [params.sortBy] - the attribute to sort results by, if any * @param {String} [params.sortOrder="ascending"] - the direction to sort results in, if sortBy is specified * @param {Number} [params.startIndex=1] - offset index that items start from * @param {Number} [params.count=20] - alias property for itemsPerPage, used only if itemsPerPage is unset * @param {Number} [params.itemsPerPage=20] - maximum number of items returned in this list response * @param {Number} [params.totalResults] - the total number of resources matching a given request * @property {[typeof SCIMMY.Messages.ListResponse.id]} schemas - list exclusively containing the SCIM ListResponse message schema ID * @property {Array<T>} Resources - resources included in the list response * @property {Number} totalResults - the total number of resources matching a given request * @property {Number} startIndex - index within total results that included resources start from * @property {Number} itemsPerPage - maximum number of items returned in this list response */ constructor(request = [], params = {}) { const outbound = Array.isArray(request); const resources = (outbound ? request : request?.Resources ?? []); const totalResults = (outbound ? params.totalResults ?? resources.length : request.totalResults); const {sortBy, sortOrder = "ascending"} = (outbound ? params : {}); const {startIndex = 1, count = 20, itemsPerPage = count} = (outbound ? params : request); // Verify the ListResponse contents are valid if (!outbound && Array.isArray(request.schemas) && (!request.schemas.includes(ListResponse.#id) || request.schemas.length > 1)) throw new TypeError(`ListResponse request body messages must exclusively specify schema as '${ListResponse.#id}'`); if (sortBy !== undefined && typeof sortBy !== "string") throw new TypeError("Expected 'sortBy' parameter to be a string in ListResponse message constructor"); if (sortBy !== undefined && !["ascending", "descending"].includes(sortOrder)) throw new TypeError("Expected 'sortOrder' parameter to be either 'ascending' or 'descending' in ListResponse message constructor"); // Check supplied itemsPerPage and startIndex are valid integers... for (let [key, val, min] of Object.entries({totalResults, itemsPerPage, startIndex}).map(([key, val], index) => ([key, val, Math.max(index - 1, 0)]))) { // ...but only expect actual number primitives when preparing an outbound list response if (Number.isNaN(Number.parseInt(val)) || !`${val}`.match(/^-?\d*$/) || (outbound && (typeof val !== "number" || !Number.isInteger(val)))) { throw new TypeError(`Expected '${key}' parameter to be a ${min ? "positive" : "non-negative"} integer in ListResponse message constructor`); } } // Construct the ListResponse message this.schemas = [ListResponse.#id]; this.Resources = resources.filter(r => r); // Constrain integer properties to their minimum values this.startIndex = Math.max(Number.parseInt(startIndex), 1); this.itemsPerPage = Math.max(Number.parseInt(itemsPerPage), 0); this.totalResults = Math.max(totalResults, 0); // Handle sorting if sortBy is defined if (sortBy !== undefined) { const paths = sortBy.split("."); // Do the sort! this.Resources = this.Resources.sort((a, b) => { // Resolve target sort values for each side of the comparison (either the "primary" entry, or first entry, in a multi-valued attribute, or the target value) const reducer = (res = {}, path = "") => ((!Array.isArray(res[path]) ? res[path] : (res[path].find(v => !!v.primary) ?? res[path][0])?.value ?? res[path][0])); const ta = paths.reduce(reducer, a); const tb = paths.reduce(reducer, b); const list = [ta, tb]; // If some or all of the targets are unspecified, sort specified value above unspecified value if (list.some(t => ((t ?? undefined) === undefined))) return ((ta ?? undefined) === (tb ?? undefined) ? 0 : (ta ?? undefined) === undefined ? 1 : -1); // If all the targets are numbers, sort by the bigger number if (list.every(t => (typeof t === "number" && !Number.isNaN(Number(t))))) return ta - tb; // If all the targets are dates, sort by the later date if (list.every(t => (String(t instanceof Date ? t.toISOString() : t) .match(/^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])(T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?)?$/)))) return new Date(ta) - new Date(tb); // If all else fails, compare the targets by string values return (String(ta).localeCompare(String(tb))); }); // Reverse the order on descending if (sortOrder === "descending") this.Resources.reverse(); } // If startIndex is within results, offset results to startIndex if ((this.Resources.length >= this.startIndex) && (this.totalResults !== this.Resources.length + this.startIndex - 1)) { this.Resources = this.Resources.slice(this.startIndex-1); } // If there are more resources than items per page, paginate the resources if (this.Resources.length > this.itemsPerPage) { this.Resources.length = this.itemsPerPage; } } } /** * List of valid SCIM patch operations * @enum SCIMMY.Messages.PatchOp~ValidPatchOperations * @inner */ const validOps = ["add", "remove", "replace"]; // Split a path by fullstops when they aren't in a filter group or decimal const pathSeparator = /(?<![^\w]\d)\.(?!\d[^\w]|[^[]*])/g; // Extract attributes and filter strings from path parts const multiValuedFilter = /^(.+?)(\[(?:.*?)])?$/g; /** * Deeply compare two objects, arrays, or primitive values to see if there are any differences * @param {Object} original - object with original property values to compare against * @param {*} original - original value to test equality against * @param {Object} current - object with potentially changed property values to search for * @param {*} current - current value to test equality against * @param {String[]} [keys] - unused placeholder for storing object keys to avoid multiple calls to Object.keys * @returns {Boolean} whether any properties or values at any level are different * @private */ const hasChanges = (original, current, keys) => ( // If the values are the same, they are unchanged... original === current ? false : // If the original value is an array... Array.isArray(original) ? ( // ...make sure the current value is also an array with matching length, then see if any values have changed (original.length !== (current ?? []).length) || (original.some((v, i) => hasChanges(v, current[i]))) // Otherwise, if the original and current values are both non-null objects, compare property values ) : (original !== null && current !== null && typeof original === "object" && typeof current === "object") ? ( // Compare underlying value of Date instances, since they are also "objects" original instanceof Date ? original.valueOf() !== current.valueOf() : // Cheaply see if key lengths differ... (keys = Object.keys(original)).length !== Object.keys(current).length ? true : // ...before expensively traversing object properties for changes (keys.some((k) => (!(k in current) || hasChanges(original[k], current[k])))) ) : ( // Fall back on whether both values are NaN (original === original && current === current) ) ); /** * SCIM Patch Operation Message * @alias SCIMMY.Messages.PatchOp * @summary * * Parses [PatchOp messages](https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2), making sure all specified "Operations" are valid and conform with the SCIM protocol. * * Provides a method to atomically apply PatchOp operations to a resource instance, handling any exceptions that occur along the way. */ class PatchOp { /** @private */ static #id = "urn:ietf:params:scim:api:messages:2.0:PatchOp"; /** * SCIM Patch Operation Message Schema ID * @type {"urn:ietf:params:scim:api:messages:2.0:PatchOp"} */ static get id() { return this.#id; } /** * Whether the PatchOp message has been fully formed. * Fully formed inbound requests will be considered to have been dispatched. * @type {Boolean} * @private */ #dispatched = false; /** * SCIM PatchOp Operation definition * @typedef {Object} SCIMMY.Messages.PatchOp~PatchOpOperation * @prop {SCIMMY.Messages.PatchOp~ValidPatchOperations} op - the operation to perform * @prop {String} [path] - an attribute path describing the target of the operation * @prop {*} [value] - value to add or update */ /** * Instantiate a new SCIM Patch Operation Message with relevant details * @param {Object} [request] - contents of the patch operation request being performed * @param {[typeof SCIMMY.Messages.PatchOp.id]} request.schemas - list exclusively containing SCIM PatchOp message schema ID * @param {SCIMMY.Messages.PatchOp~PatchOpOperation[]} request.Operations - list of SCIM-compliant patch operations to apply to the given resource * @property {[typeof SCIMMY.Messages.PatchOp.id]} schemas - list exclusively containing the SCIM PatchOp message schema ID * @property {SCIMMY.Messages.PatchOp~PatchOpOperation[]} Operations - list of SCIM-compliant patch operations to apply to the given resource */ constructor(request) { const {schemas = [], Operations: operations = []} = request ?? {}; // Determine if message is being prepared (outbound) or has been dispatched (inbound) this.#dispatched = (request !== undefined); // Make sure specified schema is valid if (this.#dispatched && (schemas.length !== 1 || !schemas.includes(PatchOp.#id))) throw new Types.Error(400, "invalidSyntax", `PatchOp request body messages must exclusively specify schema as '${PatchOp.#id}'`); // Make sure request body contains valid operations to perform if (!Array.isArray(operations)) throw new Types.Error(400, "invalidValue", "PatchOp expects 'Operations' attribute of 'request' parameter to be an array"); if (this.#dispatched && !operations.length) throw new Types.Error(400, "invalidValue", "PatchOp request body must contain 'Operations' attribute with at least one operation"); // Make sure all specified operations are valid for (let operation of operations) { const index = (operations.indexOf(operation) + 1); const {op, path, value} = operation; // Make sure operation is of type 'complex' (i.e. it's an object) if (Object(operation) !== operation || Array.isArray(operation)) throw new Types.Error(400, "invalidValue", `PatchOp request body expected value type 'complex' for operation ${index} but found type '${Array.isArray(operation) ? "collection" : typeof operation}'`); // Make sure all operations have a valid action defined if (op === undefined) throw new Types.Error(400, "invalidValue", `Missing required attribute 'op' from operation ${index} in PatchOp request body`); if (typeof op !== "string" || !validOps.includes(op.toLowerCase())) throw new Types.Error(400, "invalidSyntax", `Invalid operation '${op}' for operation ${index} in PatchOp request body`); // Make sure value attribute is specified for "add" operations if ("add" === op.toLowerCase() && value === undefined) throw new Types.Error(400, "invalidValue", `Missing required attribute 'value' for 'add' op of operation ${index} in PatchOp request body`); // Make sure path attribute is specified for "remove" operations if ("remove" === op.toLowerCase() && path === undefined) throw new Types.Error(400, "noTarget", `Missing required attribute 'path' for 'remove' op of operation ${index} in PatchOp request body`); // Make sure path attribute is a string if (path !== undefined && typeof path !== "string") throw new Types.Error(400, "invalidPath", `Invalid path '${path}' for operation ${index} in PatchOp request body`); } // Store the attributes that define a PatchOp this.schemas = [PatchOp.#id]; this.Operations = operations; } /** * SCIM SchemaDefinition instance for resource being patched * @type {SCIMMY.Types.SchemaDefinition} * @private */ #schema; /** * Original SCIM Schema resource instance being patched * @type {SCIMMY.Types.Schema} * @private */ #source; /** * Target SCIM Schema resource instance to apply patches to * @type {SCIMMY.Types.Schema} * @private */ #target; /** * Apply final transformations or database operations before determining whether a PatchOp resulted in any actual changes * @async * @template {SCIMMY.Types.Schema} [S=*] - type of schema instance that was patched * @callback SCIMMY.Messages.PatchOp~PatchOpFinaliser * @param {S} instance - a patched version of the originally supplied resource schema instance * @returns {Record<String, any>} the resource instance after final transformations have been applied */ /** * Apply patch operations to a resource as defined by the PatchOp instance * @template {SCIMMY.Types.Schema} [S=*] - type of schema instance being patched * @param {S} resource - the schema instance the patch operation will be performed on * @param {SCIMMY.Messages.PatchOp~PatchOpFinaliser<S>} [finalise] - method to call when all operations are complete, to feed target back through model * @returns {S} an instance of the resource modified as per the included patch operations */ async apply(resource, finalise) { // Bail out if message has not been dispatched (i.e. it's not ready yet) if (!this.#dispatched) throw new TypeError("PatchOp expected message to be dispatched before calling 'apply' method"); // Bail out if resource is not specified, or it's not a Schema instance if ((resource === undefined) || !(resource instanceof Types.Schema)) throw new TypeError("Expected 'resource' to be an instance of SCIMMY.Types.Schema in PatchOp 'apply' method"); // Store details about the resource being patched this.#schema = resource.constructor.definition; this.#source = resource; this.#target = new resource.constructor(resource); // Go through all specified operations for (let operation of this.Operations) { const index = (this.Operations.indexOf(operation) + 1); const {op, path, value} = operation; // And action it switch (op.toLowerCase()) { case "add": this.#add(index, path, value); break; case "remove": this.#remove(index, path, value); break; case "replace": this.#replace(index, path, value); break; default: // I don't know how we made it to here, as this should have been checked earlier, but just in case! throw new Types.Error(400, "invalidSyntax", `Invalid operation '${op}' for operation ${index} in PatchOp request body`); } } // If finalise is a method, feed it the target to retrieve final representation of resource if (typeof finalise === "function") this.#target = new this.#target.constructor(await finalise(this.#target)); // Only return value if something has changed if (hasChanges({...this.#source, meta: undefined}, {...this.#target, meta: undefined})) return this.#target; } /** * Dig in to an operation's path, making sure it is valid, and yields actual targets to patch * @param {Number} index - the operation's location in the list of operations, for use in error messages * @param {String} path - specifies path to the attribute or value being patched * @param {String} op - the operation being performed, for use in error messages * @returns {PatchOpDetails} * @private */ #resolve(index, path, op) { // Work out parts of the supplied path const paths = path.split(pathSeparator).filter(p => p); const targets = [this.#target]; let property, attribute, multiValued; try { // Remove any filters from the path and attempt to get targeted attribute definition attribute = this.#schema.attribute(paths.map(p => p.replace(multiValuedFilter, "$1")).join(".")); multiValued = attribute?.config?.multiValued ?? false; } catch { // Rethrow exceptions as SCIM errors when attribute wasn't found throw new Types.Error(400, "invalidPath", `Invalid path '${path}' for '${op}' op of operation ${index} in PatchOp request body`); } // Traverse the path while (paths.length > 0) { // Work out if path contains a filter expression const path = paths.shift(); const [, key = path, filter] = multiValuedFilter.exec(path) ?? []; // We have arrived at our destination if (paths.length === 0) { property = (!filter ? key : false); multiValued = (multiValued ? !filter : multiValued); } // Traverse deeper into each existing target for (let target of targets.splice(0)) { if (target !== undefined) try { if (filter !== undefined) { // If a filter is specified, apply it to the target and add results back to targets targets.push(...(new Types.Filter(filter.substring(1, filter.length - 1)).match(target[key]))); } else { // Add the traversed value to targets, or back out if already arrived targets.push(paths.length === 0 ? target : target[key] ?? (op === "add" ? ((target[key] = target[key] ?? {}) && target[key]) : undefined)); } } catch { // Nothing to do here, carry on } } } // No targets, bail out! if (targets.length === 0 && op !== "remove") throw new Types.Error(400, "noTarget", `Filter '${path}' does not match any values for '${op}' op of operation ${index} in PatchOp request body`); /** * @typedef {Object} PatchOpDetails * @property {Boolean} complex - whether the target attribute value should be complex * @property {Boolean} multiValued - whether the target attribute expects a collection of values * @property {String} property - name of the targeted attribute to apply values to * @property {Object[]} targets - the resources containing the attributes to apply values to * @internal * @private */ return { complex: (attribute instanceof Types.SchemaDefinition ? true : String(attribute.type) === "complex"), multiValued, property, targets }; } /** * Perform the "add" operation on the resource * @param {Number} index - the operation's location in the list of operations, for use in error messages * @param {String} path - if supplied, specifies path to the attribute being added * @param {any|any[]} value - value being added to the resource or attribute specified by path * @private */ #add(index, path, value) { if (path === undefined) { // If path is unspecified, value must be a plain object if (typeof value !== "object" || Array.isArray(value)) throw new Types.Error(400, "invalidValue", `Attribute 'value' must be an object when 'path' is empty for 'add' op of operation ${index} in PatchOp request body`); // Go through and add the data specified by value for (let [key, val] of Object.entries(value)) this.#add(index, key, val); } else { // Validate and extract details about the operation const {targets, property, multiValued, complex} = this.#resolve(index, path, "add"); // Go and apply the operation to matching targets for (let target of targets) { try { // The target is expected to be a collection if (multiValued) { // Wrap objects as arrays const values = (Array.isArray(value) ? value : [value]); // Add the values to the existing collection, or create a new one if it doesn't exist yet if (Array.isArray(target[property])) target[property].push(...values); else target[property] = values; } // The target is a complex attribute - add specified values to it else if (complex) { if (!property) Object.assign(target, value); else if (target[property] === undefined) target[property] = value; else Object.assign(target[property], value); } // The target is not a collection or a complex attribute - assign the value else target[property] = value; } catch (ex) { if (ex instanceof Types.Error) { // Add additional context to SCIM errors ex.message += ` for 'add' op of operation ${index} in PatchOp request body`; throw ex; } else if (ex.message?.endsWith?.("object is not extensible")) { // Handle errors caused by non-existent attributes in complex values throw new Types.Error(400, "invalidPath", `Invalid attribute path '${property}' in supplied value for 'add' op of operation ${index} in PatchOp request body`); } else { // Rethrow exceptions as SCIM errors throw new Types.Error(400, "invalidValue", ex.message + ` for 'add' op of operation ${index} in PatchOp request body`); } } } } } /** * Perform the "remove" operation on the resource * @param {Number} index - the operation's location in the list of operations, for use in error messages * @param {String} path - specifies path to the attribute being removed * @param {any|any[]} value - value being removed from the resource or attribute specified by path * @private */ #remove(index, path, value) { // Validate and extract details about the operation const {targets, property, complex, multiValued} = this.#resolve(index, path, "remove"); // If there's a property defined, we have an easy target for removal if (property) { // Go through and remove the property from the targets for (let target of targets) { try { // No value filter defined, or target is not multi-valued - unset the property if (value === undefined || !multiValued) target[property] = undefined; // Multivalued target, attempt removal of matching values from attribute else if (multiValued) { // Make sure filter values is an array for easy use of "includes" comparison when filtering const values = (Array.isArray(value) ? value : [value]); // If values are complex, build a filter to match with - otherwise just use values const removals = (!complex || values.every(v => Object.isFrozen(v)) ? values : ( new Types.Filter(values.map(f => Object.entries(f) // Get rid of any empty values from the filter .filter(([, value]) => value !== undefined) // Turn it into an equity filter string .map(([key, value]) => (`${key} eq ${typeof value === "string" ? `"${value}"` : value}`)) // Join all comparisons into one logical expression .join(" and ")).join(" or ")) // Get any matching values from the filter .match(target[property]) )); // Filter out any values that exist in removals list target[property] = (target[property] ?? []).filter(v => !removals.includes(v)); // Unset the property if it's now empty if (target[property].length === 0) target[property] = undefined; } } catch (ex) { if (ex instanceof Types.Error) { // Add additional context to SCIM errors ex.message += ` for 'remove' op of operation ${index} in PatchOp request body`; throw ex; } else if (ex.message?.endsWith?.("object is not extensible")) { // Handle errors caused by non-existent attributes in complex values throw new Types.Error(400, "invalidPath", `Invalid attribute path '${property}' in supplied value for 'remove' op of operation ${index} in PatchOp request body`); } else { // Rethrow exceptions as SCIM errors throw new Types.Error(400, "invalidValue", ex.message + ` for 'remove' op of operation ${index} in PatchOp request body`); } } } } else { // Get path to the parent attribute having values removed const parentPath = path.split(pathSeparator).filter(v => v) .map((path, index, paths) => (index < paths.length-1 ? path : path.replace(multiValuedFilter, "$1"))) .join("."); // Remove targeted values from parent attributes this.#remove(index, parentPath, targets); } } /** * Perform the "replace" operation on the resource * @param {Number} index - the operation's location in the list of operations, for use in error messages * @param {String} path - specifies path to the attribute being replaced * @param {any|any[]} value - value being replaced from the resource or attribute specified by path * @private */ #replace(index, path, value) { try { // Call remove, then call add! try { if (path !== undefined) this.#remove(index, path); } catch { // Do nothing, as we're immediately adding a new value, which will enforce actual attribute validity } try { // Try set the value at the path this.#add(index, path, value); } catch (ex) { // If it's a multi-value target that doesn't exist, add to the collection instead if (ex.scimType === "noTarget") { this.#add(index, path.split(pathSeparator).filter(p => p) .map((p, i, s) => (i < s.length - 1 ? p : p.replace(multiValuedFilter, "$1"))).join("."), value); } // Otherwise, rethrow the error else throw ex; } } catch (ex) { // Rethrow exceptions with 'replace' instead of 'add' or 'remove' ex.message = ex.message.replace(/for '(add|remove)' op/, "for 'replace' op"); throw ex; } } } /** * SCIM Bulk Response Message * @alias SCIMMY.Messages.BulkResponse * @since 1.0.0 * @summary * * Encapsulates bulk operation results as [BulkResponse messages](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7) for consumption by a client. * * Provides a method to unwrap BulkResponse results into operation success status, and map newly created resource IDs to their BulkRequest bulkIds. */ class BulkResponse { /** @private */ static #id = "urn:ietf:params:scim:api:messages:2.0:BulkResponse"; /** * SCIM BulkResponse Message Schema ID * @type {"urn:ietf:params:scim:api:messages:2.0:BulkResponse"} */ static get id() { return this.#id; } /** * BulkResponse operation response status codes * @enum {200|201|204|307|308|400|401|403|404|409|412|500|501} SCIMMY.Messages.BulkResponse~ResponseStatusCodes * @inner */ /** * BulkResponse operation details for a given BulkRequest operation * @typedef {Object} SCIMMY.Messages.BulkResponse~BulkOpResponse * @property {String} [location] - canonical URI for the target resource of the operation * @property {SCIMMY.Messages.BulkRequest~ValidBulkMethods} method - the HTTP method used for the requested operation * @property {String} [bulkId] - the transient identifier of a newly created resource, unique within a bulk request and created by the client * @property {String} [version] - resource version after operation has been applied * @property {SCIMMY.Messages.BulkResponse~ResponseStatusCodes} status - the HTTP response status code for the requested operation * @property {Object} [response] - the HTTP response body for the specified request operation * @inner */ /** * Instantiate a new outbound SCIM BulkResponse message from the results of performed operations * @overload * @param {SCIMMY.Messages.BulkResponse~BulkOpResponse[]} operations - results of performed operations */ /** * Instantiate a new inbound SCIM BulkResponse message instance from the received response * @overload * @param {Object} request - contents of the received BulkResponse message * @param {SCIMMY.Messages.BulkResponse~BulkOpResponse[]} request.Operations - list of SCIM-compliant bulk operation results */ /** * Instantiate a new SCIM BulkResponse message from the supplied Operations * @param {SCIMMY.Messages.BulkResponse~BulkOpResponse[]} request - results of performed operations if array * @param {Object} request - contents of the received BulkResponse message if object * @param {SCIMMY.Messages.BulkResponse~BulkOpResponse[]} request.Operations - list of SCIM-compliant bulk operation results * @property {[typeof SCIMMY.Messages.BulkResponse.id]} schemas - list exclusively containing the SCIM BulkResponse message schema ID * @property {SCIMMY.Messages.BulkResponse~BulkOpResponse[]} Operations - list of BulkResponse operation results */ constructor(request = []) { const outbound = Array.isArray(request); const operations = (outbound ? request : request?.Operations ?? []); // Verify the BulkResponse contents are valid if (!outbound && Array.isArray(request?.schemas) && (!request.schemas.includes(BulkResponse.#id) || request.schemas.length > 1)) throw new TypeError(`BulkResponse request body messages must exclusively specify schema as '${BulkResponse.#id}'`); if (!Array.isArray(operations)) throw new TypeError("BulkResponse constructor expected 'Operations' property of 'request' parameter to be an array"); if (!outbound && !operations.length) throw new TypeError("BulkResponse request body must contain 'Operations' attribute with at least one operation"); // All seems OK, prepare the BulkResponse this.schemas = [BulkResponse.#id]; this.Operations = [...operations]; } /** * Resolve bulkIds of POST operations into new resource IDs * @returns {Map<String, String|Boolean>} map of bulkIds to resource IDs if operation was successful, or false if not */ resolve() { return new Map(this.Operations // Only target POST operations with valid bulkIds .filter(o => String(o.method) === "POST" && !!o.bulkId && typeof o.bulkId === "string") .map(o => ([o.bulkId, (typeof o.location === "string" && !!o.location ? o.location.split("/").pop() : false)]))); } } /** * List of valid HTTP methods in a SCIM bulk request operation * @enum SCIMMY.Messages.BulkRequest~ValidBulkMethods * @inner */ const validMethods = ["POST", "PUT", "PATCH", "DELETE"]; /** * SCIM Bulk Request Message * @alias SCIMMY.Messages.BulkRequest * @since 1.0.0 * @summary * * Parses [BulkRequest messages](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7), making sure "Operations" have been specified, and conform with the SCIM protocol. * * Provides a method to apply BulkRequest operations and return the results as a BulkResponse. */ class BulkRequest { /** @private */ static #id = "urn:ietf:params:scim:api:messages:2.0:BulkRequest"; /** * SCIM BulkRequest Message Schema ID * @type {"urn:ietf:params:scim:api:messages:2.0:BulkRequest"} */ static get id() { return this.#id; } /** * Whether the incoming BulkRequest has been applied * @type {Boolean} * @private */ #dispatched = false; /** * BulkRequest operation details * @typedef {Object} SCIMMY.Messages.BulkRequest~BulkOpOperation * @property {SCIMMY.Messages.BulkRequest~ValidBulkMethods} method - the HTTP method used for the requested operation * @property {String} [bulkId] - the transient identifier of a newly created resource, unique within a bulk request and created by the client * @property {String} [version] - resource version after operation has been applied * @property {String} [path] - the resource's relative path to the SCIM service provider's root * @property {Object} [data] - the resource data as it would appear for the corresponding single SCIM HTTP request * @inner */ /** * Instantiate a new SCIM BulkRequest message from the supplied operations * @param {Object} request - contents of the BulkRequest operation being performed * @param {[typeof SCIMMY.Messages.BulkRequest.id]} request.schemas - list exclusively containing the SCIM BulkRequest message schema ID * @param {SCIMMY.Messages.BulkRequest~BulkOpOperation[]} request.Operations - list of SCIM-compliant bulk operations to apply * @param {Number} [request.failOnErrors] - number of error results to encounter before aborting any following operations * @param {Number} [maxOperations] - maximum number of operations supported in the request, as specified by the service provider * @property {[typeof SCIMMY.Messages.BulkRequest.id]} schemas - list exclusively containing the SCIM BulkRequest message schema ID * @property {SCIMMY.Messages.BulkRequest~BulkOpOperation[]} Operations - list of operations in this BulkRequest instance * @property {Number} [failOnErrors] - number of error results a service provider should tolerate before aborting any following operations */ constructor(request, maxOperations = 0) { const {schemas = [], Operations: operations = [], failOnErrors = 0} = request ?? {}; // Make sure specified schema is valid if (schemas.length !== 1 || !schemas.includes(BulkRequest.#id)) throw new Types.Error(400, "invalidSyntax", `BulkRequest request body messages must exclusively specify schema as '${BulkRequest.#id}'`); // Make sure failOnErrors is a valid integer if (typeof failOnErrors !== "number" || !Number.isInteger(failOnErrors) || failOnErrors < 0) throw new Types.Error(400, "invalidSyntax", "BulkRequest expected 'failOnErrors' attribute of 'request' parameter to be a positive integer"); // Make sure maxOperations is a valid integer if (typeof maxOperations !== "number" || !Number.isInteger(maxOperations) || maxOperations < 0) throw new Types.Error(400, "invalidSyntax", "BulkRequest expected 'maxOperations' parameter to be a positive integer"); // Make sure request body contains valid operations to perform if (!Array.isArray(operations)) throw new Types.Error(400, "invalidValue", "BulkRequest expected 'Operations' attribute of 'request' parameter to be an array"); if (!operations.length) throw new Types.Error(400, "invalidValue", "BulkRequest request body must contain 'Operations' attribute with at least one operation"); if (maxOperations > 0 && operations.length > maxOperations) throw new Types.Error(413, null, `Number of operations in BulkRequest exceeds maxOperations limit (${maxOperations})`); // All seems OK, prepare the BulkRequest body this.schemas = [BulkRequest.#id]; this.Operations = [...operations]; if (failOnErrors) this.failOnErrors = failOnErrors; } /** * Apply the operations specified by the supplied BulkRequest and return a new BulkResponse message * @param {typeof SCIMMY.Types.Resource[]} [resourceTypes] - resource type classes to be used while processing bulk operations, defaults to declared resources * @param {*} [ctx] - any additional context information to pass to the ingress, egress, and degress handlers * @returns {SCIMMY.Messages.BulkResponse} a new BulkResponse Message instance with results of the requested operations */ async apply(resourceTypes = Object.values(Resources.declared()), ctx) { // Bail out if BulkRequest message has already been applied if (this.#dispatched) throw new TypeError("BulkRequest 'apply' method must not be called more than once"); // Make sure all specified resource types extend the Resource type class so operations can be processed correctly else if (!resourceTypes.every(r => r.prototype instanceof Types.Resource)) throw new TypeError("Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of BulkRequest"); // Seems OK, mark the BulkRequest as dispatched so apply can't be called again else this.#dispatched = true; // Set up easy access to resource types by endpoint, and store pending results const typeMap = new Map(resourceTypes.map((r) => [r.endpoint, r])); const results = []; // Get a map of POST ops with bulkIds for direct and circular reference resolution const bulkIds = new Map(this.Operations .filter(o => String(o.method) === "POST" && !!o.bulkId && typeof o.bulkId === "string") .map(({bulkId}, index, postOps) => { // Establish who waits on what, and provide a way for that to happen const handlers = {referencedBy: postOps.filter(({data}) => JSON.stringify(data ?? {}).includes(`bulkId:${bulkId}`)).map(({bulkId}) => bulkId)}; const value = new Promise((resolve, reject) => Object.assign(handlers, {resolve, reject})); return [bulkId, Object.assign(value, handlers)]; }) ); // Turn them into a list for operation ordering const bulkIdTransients = [...bulkIds.keys()]; // Establish error handling for the entire list of operations const errorLimit = this.failOnErrors; let errorCount = 0, lastErrorIndex = this.Operations.length + 1; for (let op of this.Operations) results.push((async () => { // Unwrap useful information from the operation const {method, bulkId: opBulkId, path = "", data} = op; // Ignore the bulkId unless method is POST const bulkId = (String(method).toUpperCase() === "POST" ? opBulkId : undefined); // Evaluate endpoint and resource ID, and thus what kind of resource we're targeting const [endpoint, id] = (typeof path === "string" ? path : "").substring(1).split("/"); const TargetResource = (endpoint ? typeMap.get(`/${endpoint}`) : false); // Construct a location