scimmy
Version:
SCIMMY - SCIM m(ade eas)y
895 lines (802 loc) • 70.5 kB
JavaScript
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