scimmy
Version:
SCIMMY - SCIM m(ade eas)y
900 lines (828 loc) • 145 kB
JavaScript
/**
* Base Attribute configuration, and proxied configuration validation trap handler
* @type {{target: SCIMMY.Types.Attribute~AttributeConfig, handler: ProxiedConfigHandler}}
* @private
*/
const BaseConfiguration = {
/**
* SCIMMY Attribute Instance Configuration properties
* @typedef {Object} SCIMMY.Types.Attribute~AttributeConfig
* @property {Boolean} [multiValued=false] - does the attribute expect a collection of values
* @property {String} [description=""] - a human-readable description of the attribute
* @property {Boolean} [required=false] - whether the attribute is required for the type instance to be valid
* @property {Boolean|String[]} [canonicalValues=false] - values the attribute's contents must be set to
* @property {Boolean} [caseExact=false] - whether the attribute's contents is case-sensitive
* @property {Boolean|String} [mutable=true] - whether the attribute's contents is modifiable
* @property {Boolean|String} [returned=true] - whether the attribute is returned in a response
* @property {Boolean|String[]} [referenceTypes=false] - list of referenced types if attribute type is reference
* @property {String|Boolean} [uniqueness="none"] - the attribute's uniqueness characteristic
* @property {String} [direction="both"] - whether the attribute should be present for inbound, outbound, or bidirectional requests
* @property {Boolean} [shadow=false] - whether the attribute should be hidden from schemas presented by the resource type endpoint
*/
target: {
shadow: false, required: false, mutable: true, multiValued: false, caseExact: false, returned: true,
description: "", canonicalValues: false, referenceTypes: false, uniqueness: "none", direction: "both"
},
/**
* Proxied configuration validation trap handler
* @alias ProxiedConfigHandler
* @param {String} errorSuffix - the suffix to use in thrown type errors
* @param {String} type - the attribute type for which configuration is being proxied
* @returns {ProxyHandler<Object>} the handler trap definition to use in the config proxy
* @private
*/
handler: (errorSuffix, type) => ({
deleteProperty: (target, key) => {
// Prevent removal of known properties from attribute config
if (key in BaseConfiguration.target) throw new TypeError(`Cannot remove known property '${key}' from configuration of ${errorSuffix}`);
// Otherwise, delete the property from the target
else return Reflect.deleteProperty(target, key);
},
defineProperty: (target, key, descriptor) => {
// Only allow known properties to be defined on attribute config
if (key in BaseConfiguration.target) return Reflect.defineProperty(target, key, descriptor);
// Otherwise, throw an exception explaining the above
else throw new TypeError(`Cannot add unknown property '${key}' to configuration of ${errorSuffix}`);
},
get: (target, key) => {
// For binary attribute configuration...
if (type === "binary") {
// ...always return true for 'caseExact', and
if (key === "caseExact") return true;
// ...always return 'none' for 'uniqueness'
if (key === "uniqueness") return "none";
}
// Otherwise, return actual value
return Reflect.get(target, key);
},
set: (target, key, value) => {
// Make sure the property is known before setting any value
if (!(key in BaseConfiguration.target))
throw new TypeError(`Cannot add unknown property '${key}' to configuration of ${errorSuffix}`);
// Make sure binary attributes only accept 'caseExact' values of true
if (type === "binary" && key === "caseExact" && value !== true)
throw new TypeError(`Attribute type 'binary' must specify 'caseExact' value as 'true' in ${errorSuffix}`);
// Make sure binary attributes only accept 'uniqueness' values of 'none'
if (type === "binary" && key === "uniqueness" && value !== "none")
throw new TypeError(`Attribute type 'binary' must specify 'uniqueness' value as 'none' in ${errorSuffix}`);
// Make sure required, multiValued, and caseExact are booleans
if (["required", "multiValued", "caseExact", "shadow"].includes(key) && (value !== undefined && typeof value !== "boolean"))
throw new TypeError(`Attribute '${key}' value must be either 'true' or 'false' in ${errorSuffix}`);
// Make sure canonicalValues and referenceTypes are valid if they are specified
if (["canonicalValues", "referenceTypes"].includes(key) && (value !== undefined && value !== false && !Array.isArray(value)))
throw new TypeError(`Attribute '${key}' value must be either a collection or 'false' in ${errorSuffix}`);
// Make sure mutability, returned, and uniqueness config values are valid
if (["mutable", "returned", "uniqueness"].includes(key)) {
let label = (key === "mutable" ? "mutability" : key);
if ((typeof value === "string" && !CharacteristicValidity[label].includes(value)))
throw new TypeError(`Attribute '${label}' value '${value}' not recognised in ${errorSuffix}`);
else if (value !== undefined && !["string", "boolean"].includes(typeof value))
throw new TypeError(`Attribute '${label}' value must be either string or boolean in ${errorSuffix}`);
}
// Set the value!
return Reflect.set(target, key, value);
}
})
};
/**
* Valid values for various Attribute characteristics
* @type {{types: ValidAttributeTypes, mutability: ValidMutabilityValues, returned: ValidReturnedValues, uniqueness: ValidUniquenessValues}}
* @private
*/
const CharacteristicValidity = {
/**
* Collection of valid attribute type characteristic's values
* @enum SCIMMY.Types.Attribute~ValidAttributeTypes
* @inner
*/
types: ["string", "complex", "boolean", "binary", "decimal", "integer", "dateTime", "reference"],
/**
* Collection of valid attribute mutability characteristic's values
* @enum SCIMMY.Types.Attribute~ValidMutabilityValues
* @inner
*/
mutability: ["readOnly", "readWrite", "immutable", "writeOnly"],
/**
* Collection of valid attribute returned characteristic's values
* @enum SCIMMY.Types.Attribute~ValidReturnedValues
* @inner
*/
returned: ["always", "never", "default", "request"],
/**
* Collection of valid attribute uniqueness characteristic's values
* @enum SCIMMY.Types.Attribute~ValidUniquenessValues
* @inner
*/
uniqueness: ["none", "server", "global"]
};
/**
* Attribute value validation method container
* @type {{canonical: validate.canonical, string: validate.string, date: validate.date, number: validate.number, reference: validate.reference}}
* @private
*/
const validate = {
/**
* If the attribute has canonical values, make sure value is one of them
* @param {SCIMMY.Types.Attribute} attrib - the attribute performing the validation
* @param {*} value - the value being validated
*/
canonical: (attrib, value) => {
if (Array.isArray(attrib.config.canonicalValues) && !attrib.config.canonicalValues.includes(value))
throw new TypeError(`Attribute '${attrib.name}' does not include canonical value '${value}'`);
},
/**
* If the attribute type is string, make sure value can safely be cast to string
* @param {SCIMMY.Types.Attribute} attrib - the attribute performing the validation
* @param {*} value - the value being validated
*/
string: (attrib, value) => {
if (typeof value !== "string" && value !== null) {
const type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value);
// Catch array and object values as they will not cast to string as expected
throw new TypeError(`Attribute '${attrib.name}' expected ` + (Array.isArray(value)
? "single value of type 'string'" : `value type 'string' but found type '${type}'`));
}
},
/**
* Check if value is a valid date
* @param {SCIMMY.Types.Attribute} attrib - the attribute performing the validation
* @param {*} value - the value being validated
*/
date: (attrib, value) => {
const date = new Date(value);
const type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value);
// Reject values that definitely aren't dates
if (["number", "complex", "boolean"].includes(type) || (type === "string" && date.toString() === "Invalid Date"))
throw new TypeError(`Attribute '${attrib.name}' expected ` + (Array.isArray(value)
? "single value of type 'dateTime'" : `value type 'dateTime' but found type '${type}'`));
// Start with the simple date validity test
else if (!(date.toString() !== "Invalid Date"
// Move on to the complex test, as for some reason strings like "Testing, 1, 2" parse as valid dates...
&& date.toISOString().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])?)?$/)))
throw new TypeError(`Attribute '${attrib.name}' expected value to be a valid date`);
},
/**
* If the attribute type is decimal or integer, make sure value can safely be cast to number
* @param {SCIMMY.Types.Attribute} attrib - the attribute performing the validation
* @param {*} value - the value being validated
*/
number: (attrib, value) => {
const {type, name} = attrib;
const isNum = !!String(value).match(/^-?\d+?(\.\d+)?$/);
const isInt = isNum && !String(value).includes(".");
const actual = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value);
if (typeof value === "object" && value !== null) {
// Catch case where value is an object or array
throw new TypeError(`Attribute '${name}' expected ` + (Array.isArray(value)
? `single value of type '${type}'` : `value type '${type}' but found type '${actual}'`));
}
// Not a number
if (!isNum)
throw new TypeError(`Attribute '${name}' expected value type '${type}' but found type '${actual}'`);
// Expected decimal, got integer
if (type === "decimal" && isInt)
throw new TypeError(`Attribute '${name}' expected value type 'decimal' but found type 'integer'`);
// Expected integer, got decimal
if (type === "integer" && !isInt)
throw new TypeError(`Attribute '${name}' expected value type 'integer' but found type 'decimal'`);
},
/**
* If the attribute type is binary, make sure value can safely be cast to buffer
* @param {SCIMMY.Types.Attribute} attrib - the attribute performing the validation
* @param {*} value - the value being validated
*/
binary: (attrib, value) => {
let message;
if (typeof value === "object" && value !== null) {
const type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value);
// Catch case where value is an object or array
if (Array.isArray(value)) message = `Attribute '${attrib.name}' expected single value of type 'binary'`;
else message = `Attribute '${attrib.name}' expected value type 'binary' but found type '${type}'`;
} else {
// Start by assuming value is not binary or base64
message = `Attribute '${attrib.name}' expected value type 'binary' to be base64 encoded string or binary octet stream`;
try {
message = (!!Buffer.from(value) ? false : message);
} catch {
// Value is invalid, nothing to do here
}
}
// If there is a message, throw it!
if (!!message) throw new TypeError(message);
},
/**
* If the attribute type is boolean, make sure value is a boolean
* @param {SCIMMY.Types.Attribute} attrib - the attribute performing the validation
* @param {*} value - the value being validated
*/
boolean: (attrib, value) => {
if (typeof value !== "boolean" && value !== null) {
const type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value);
// Catch array and object values as they will not cast to string as expected
throw new TypeError(`Attribute '${attrib.name}' expected ` + (Array.isArray(value)
? "single value of type 'boolean'" : `value type 'boolean' but found type '${type}'`));
}
},
/**
* If the attribute type is reference, make sure value is a reference
* @param {SCIMMY.Types.Attribute} attrib - the attribute performing the validation
* @param {*} value - the value being validated
*/
reference: (attrib, value) => {
const listReferences = (attrib.config.referenceTypes || []).map(t => `'${t}'`).join(", ");
const coreReferences = (attrib.config.referenceTypes || []).filter(t => ["uri", "external"].includes(t));
const typeReferences = (attrib.config.referenceTypes || []).filter(t => !["uri", "external"].includes(t));
let message;
// If there's no value and the attribute isn't required, skip validation
if (value === undefined && !attrib?.config?.required) return;
else if (typeof value !== "string" && value !== null) {
const type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value);
// Catch case where value is an object or array
if (Array.isArray(value)) message = `Attribute '${attrib.name}' expected single value of type 'reference'`;
else message = `Attribute '${attrib.name}' expected value type 'reference' but found type '${type}'`;
} else if (listReferences.length === 0) {
// If the referenceTypes list is empty, no value can match
message = `Attribute '${attrib.name}' with type 'reference' does not specify any referenceTypes`;
} else {
// Start by assuming no reference types match
message = `Attribute '${attrib.name}' expected value type 'reference' to refer to one of: ${listReferences}`;
// Check for any valid resource type references, if any provided
if (typeReferences.some(t => (String(value).startsWith(t) || (String(value).includes(`/${t}`))))) {
message = false;
}
// If reference types includes external, make sure value is a valid URL with hostname
if (coreReferences.includes("external")) {
try {
message = (!!new URL(value).hostname ? false : message);
} catch {
// Value is invalid, nothing to do here
}
}
// If reference types includes URI, make sure value can be instantiated as a URL
if (coreReferences.includes("uri")) {
try {
// See if it can be parsed as a URL
message = (new URL(value) ? false : message);
} catch {
// See if it's a relative URI
message = (String(value).startsWith("/") ? false : message);
}
}
}
// If there is a message, throw it!
if (!!message) throw new TypeError(message);
}
};
/**
* SCIM Attribute Type
* @alias SCIMMY.Types.Attribute
* @summary
* * Defines a SCIM schema attribute, and is used to ensure a given resource's value conforms to the attribute definition.
*/
class Attribute {
/**
* Constructs an instance of a full SCIM attribute definition
* @param {SCIMMY.Types.Attribute~ValidAttributeTypes} type - the data type of the attribute
* @param {String} name - the actual name of the attribute
* @param {SCIMMY.Types.Attribute~AttributeConfig} [config] - additional config defining the attribute's characteristics
* @param {SCIMMY.Types.Attribute[]} [subAttributes] - if the attribute is complex, the sub-attributes of the attribute
* @property {SCIMMY.Types.Attribute~ValidAttributeTypes} type - the data type of the attribute
* @property {String} name - the actual name of the attribute
* @property {SCIMMY.Types.Attribute~AttributeConfig} config - additional config defining the attribute's characteristics
* @property {SCIMMY.Types.Attribute[]} [subAttributes] - if the attribute is complex, the sub-attributes of the attribute
*/
constructor(type, name, config = {}, subAttributes = []) {
const errorSuffix = `attribute definition '${name}'`;
// Check for invalid characters in attribute name
const [, invalidNameChar, invalidNameStart] = /([^-$\w])|(^[^-$\w])/g.exec(name) ?? [];
// Make sure name and type are supplied as strings
for (let [param, value] of [["type", type], ["name", name]]) if (typeof value !== "string")
throw new TypeError(`Required parameter '${param}' missing from Attribute instantiation`);
// Make sure type is valid
if (!CharacteristicValidity.types.includes(type))
throw new TypeError(`Type '${type}' not recognised in ${errorSuffix}`);
// Make sure first character in name is valid
if (!!invalidNameStart)
throw new TypeError(`Invalid leading character '${invalidNameStart}' in name of ${errorSuffix}`);
// Make sure rest of name is valid
if (!!invalidNameChar)
throw new TypeError(`Invalid character '${invalidNameChar}' in name of ${errorSuffix}`);
// Make sure attribute type is 'complex' if subAttributes are defined
if (subAttributes.length && String(type) !== "complex")
throw new TypeError(`Attribute type must be 'complex' when subAttributes are specified in ${errorSuffix}`);
// Make sure subAttributes are all instances of Attribute
if (String(type) === "complex" && !subAttributes.every(a => a instanceof Attribute))
throw new TypeError(`Expected 'subAttributes' to be an array of Attribute instances in ${errorSuffix}`);
// Attribute config is valid, proceed
this.type = type;
this.name = name;
// Prevent addition and removal of properties from config
this.config = Object.assign(Object.seal(new Proxy({...BaseConfiguration.target}, BaseConfiguration.handler(errorSuffix, type))), config);
// Store subAttributes, and make sure any additions are also attribute instances
if (String(type) === "complex") this.subAttributes = new Proxy([...subAttributes], {
set: (target, key, value) => {
if (key === "length" || value instanceof Attribute) target[key] = value;
else throw new TypeError(`Complex attribute '${this.name}' expected new subAttributes to be Attribute instances`);
return key === "length" || target.includes(value);
}
});
// Prevent this attribute definition from changing!
// Note: config and subAttributes can still be modified, just not replaced.
Object.freeze(this);
}
/**
* Remove a subAttribute from a complex attribute definition
* @param {String|SCIMMY.Types.Attribute} subAttributes - the child attributes to remove from the complex attribute definition
* @returns {SCIMMY.Types.Attribute} this attribute instance for chaining
*/
truncate(subAttributes) {
if (String(this.type) === "complex") {
for (let subAttrib of (Array.isArray(subAttributes) ? subAttributes : [subAttributes])) {
if (this.subAttributes.includes(subAttrib)) {
// Remove found subAttribute from definition
const index = this.subAttributes.indexOf(subAttrib);
if (index >= 0) this.subAttributes.splice(index, 1);
} else if (typeof subAttrib === "string") {
// Attempt to find the subAttribute by name and try truncate again
this.truncate(this.subAttributes.find(a => a.name === subAttrib));
}
}
}
return this;
}
/**
* Parse this Attribute instance into a valid SCIM attribute definition object
* @returns {SCIMMY.Types.Attribute~AttributeDefinition} an object representing a valid SCIM attribute definition
*/
toJSON() {
/**
* SCIM Attribute Definition properties
* @typedef {Object} SCIMMY.Types.Attribute~AttributeDefinition
* @property {String} name - the attribute's name
* @property {SCIMMY.Types.Attribute~ValidAttributeTypes} type - the attribute's data type
* @property {String[]} [referenceTypes] - specifies a SCIM resourceType that a reference attribute may refer to
* @property {Boolean} multiValued - boolean value indicating an attribute's plurality
* @property {String} description - a human-readable description of the attribute
* @property {Boolean} required - boolean value indicating whether the attribute is required
* @property {SCIMMY.Types.Attribute~AttributeDefinition[]} [subAttributes] - defines the sub-attributes of a complex attribute
* @property {Boolean} [caseExact] - boolean value indicating whether a string attribute is case-sensitive
* @property {String[]} [canonicalValues] - collection of canonical values
* @property {String} mutability - indicates whether an attribute is modifiable
* @property {String} returned - indicates when an attribute is returned in a response
* @property {String} [uniqueness] - indicates how unique a value must be
*/
return {
name: this.name,
type: this.type,
...(String(this.type) === "reference" ? {referenceTypes: this.config.referenceTypes} : {}),
multiValued: this.config.multiValued,
description: this.config.description,
required: this.config.required,
...(String(this.type) === "complex" ? {subAttributes: this.subAttributes.filter(a => (!a.config.shadow))} : {}),
...(this.config.caseExact === true || ["string", "reference", "binary"].includes(this.type) ? {caseExact: this.config.caseExact} : {}),
...(Array.isArray(this.config.canonicalValues) ? {canonicalValues: this.config.canonicalValues} : {}),
mutability: (typeof this.config.mutable === "string" ? this.config.mutable
: (this.config.mutable ? (this.config.direction === "in" ? "writeOnly" : "readWrite") : "readOnly")),
returned: (typeof this.config.returned === "string" ? this.config.returned
: (this.config.returned ? "default" : "never")),
...(String(this.type) !== "boolean" && this.config.uniqueness !== false ? {uniqueness: this.config.uniqueness} : {})
}
}
/**
* Coerce a given value by making sure it conforms to attribute's characteristics
* @param {any|any[]} source - value to coerce and confirm conformity with attribute's characteristics
* @param {String} [direction="both"] - whether to check for inbound, outbound, or bidirectional attributes
* @param {Boolean} [isComplexMultiValue=false] - indicates whether a coercion is for a single complex value in a collection of complex values
* @returns {String|String[]|Number|Boolean|Object|Object[]} the coerced value, conforming to attribute's characteristics
*/
coerce(source, direction = "both", isComplexMultiValue = false) {
// Make sure the direction matches the attribute direction
if (["both", this.config.direction].includes(direction) || this.config.direction === "both") {
const {required, multiValued, canonicalValues} = this.config;
// If the attribute is required, make sure it has a value
if ((source === undefined || source === null) && required && (direction !== "both" || this.config.direction === direction))
throw new TypeError(`Required attribute '${this.name}' is missing`);
// If the attribute is multi-valued, make sure its value is a collection
if (source !== undefined && !isComplexMultiValue && multiValued && !Array.isArray(source))
throw new TypeError(`Attribute '${this.name}' expected to be a collection`);
// If the attribute is NOT multi-valued, make sure its value is NOT a collection
if (!multiValued && Array.isArray(source))
throw new TypeError(`Attribute '${this.name}' is not multi-valued and must not be a collection`);
// If the attribute specifies canonical values, make sure all values are valid
if (source !== undefined && Array.isArray(canonicalValues) && (!(multiValued ? (source ?? []).every(v => canonicalValues.includes(v)) : canonicalValues.includes(source))))
throw new TypeError(`Attribute '${this.name}' contains non-canonical value`);
// If the source has a value, parse it
if (source !== undefined && source !== null) switch (this.type) {
case "string":
// Throw error if all values can't be safely cast to strings
for (let value of (multiValued ? source : [source])) validate.string(this, value);
// Cast supplied values into strings
return (!multiValued ? String(source) : new Proxy(source.map(v => String(v)), {
// Wrap the resulting collection with coercion
set: (target, key, value) => (!!(key in Object.getPrototypeOf([]) && key !== "length" ? false :
(target[key] = (key === "length" ? value :
validate.canonical(this, value) ?? validate.string(this, value) ?? String(value)))))
}));
case "dateTime":
// Throw error if all values aren't valid dates
for (let value of (multiValued ? source : [source])) validate.date(this, value);
// Convert date values to ISO strings
return (!multiValued ? new Date(source).toISOString() : new Proxy(source.map(v => new Date(v).toISOString()), {
// Wrap the resulting collection with coercion
set: (target, key, value) => (!!(key in Object.getPrototypeOf([]) && key !== "length" ? false :
(target[key] = (key === "length" ? value :
validate.canonical(this, value) ?? validate.date(this, value) ?? new Date(value).toISOString()))))
}));
case "decimal":
case "integer":
// Throw error if all values can't be safely cast to numbers
for (let value of (multiValued ? source : [source])) validate.number(this, value);
// Cast supplied values into numbers
return (!multiValued ? Number(source) : new Proxy(source.map(v => Number(v)), {
// Wrap the resulting collection with coercion
set: (target, key, value) => (!!(key in Object.getPrototypeOf([]) && key !== "length" ? false :
(target[key] = (key === "length" ? value :
validate.canonical(this, value) ?? validate.number(this, value) ?? Number(value)))))
}));
case "reference":
// Throw error if all values can't be safely cast to strings
for (let value of (multiValued ? source : [source])) validate.reference(this, value);
// Cast supplied values into strings
return (!multiValued ? String(source) : new Proxy(source.map(v => String(v)), {
// Wrap the resulting collection with coercion
set: (target, key, value) =>(!!(key in Object.getPrototypeOf([]) && key !== "length" ? false :
(target[key] = (key === "length" ? value :
validate.canonical(this, value) ?? validate.reference(this, value) ?? String(value)))))
}));
case "binary":
// Throw error if all values can't be safely cast to buffers
for (let value of (multiValued ? source : [source])) validate.binary(this, value);
// Cast supplied values into strings
return (!multiValued ? String(source) : new Proxy(source.map(v => String(v)), {
// Wrap the resulting collection with coercion
set: (target, key, value) => (!!(key in Object.getPrototypeOf([]) && key !== "length" ? false :
(target[key] = (key === "length" ? value :
validate.canonical(this, value) ?? validate.binary(this, value) ?? String(value)))))
}));
case "boolean":
// Throw error if all values can't be safely cast to booleans
for (let value of (multiValued ? source : [source])) validate.boolean(this, value);
// Cast supplied values into booleans
return (!multiValued ? !!source : new Proxy(source.map(v => !!v), {
// Wrap the resulting collection with coercion
set: (target, key, value) => (!!(key in Object.getPrototypeOf([]) && key !== "length" ? false :
(target[key] = (key === "length" ? value : validate.boolean(this, value) ?? !!value)) || true))
}));
case "complex":
// Prepare for a complex attribute's values
let target = (!isComplexMultiValue ? [] : {});
// Evaluate complex attribute's sub-attributes
if (isComplexMultiValue) {
// Make sure values are complex before proceeding
if (Object(source) !== source || source instanceof Date) {
throw new TypeError(`Complex attribute '${this.name}' expected complex value but found type `
+ `'${source instanceof Date ? "dateTime" : source === null ? "null" : typeof source}'`);
}
let resource = {};
// Go through each sub-attribute for coercion
for (let subAttribute of this.subAttributes) {
const {name} = subAttribute;
// Predefine getters and setters for all possible sub-attributes
Object.defineProperties(target, {
// Because why bother with case-sensitivity in a JSON-based standard?
// See: RFC7643§2.1 (https://datatracker.ietf.org/doc/html/rfc7643#section-2.1)
[name.toLowerCase()]: {
get: () => (target[name]),
set: (value) => (target[name] = value)
},
// Now set the handles for the actual name
// Overrides above if name is already all lower case
[name]: {
enumerable: true,
// Get and set the value from the internally scoped object
get: () => (resource[name]),
// Validate the supplied value through attribute coercion
set: (value) => {
try {
return (resource[name] = subAttribute.coerce(value, direction))
} catch (ex) {
// Add additional context
ex.message += ` from complex attribute '${this.name}'`;
throw ex;
}
}
}
});
}
// Set "toJSON" method on target so subAttributes can be filtered
Object.defineProperty(target, "toJSON", {
value: () => Object.entries(resource)
.filter(([name]) => ![false, "never"].includes(this.subAttributes.find(a => a.name === name).config.returned))
.reduce((res, [name, value]) => Object.assign(res, {[name]: value}), {})
});
// Prevent changes to target
Object.freeze(target);
// Then add specified values to the target, invoking sub-attribute coercion
for (let [key, value] of Object.entries(source)) try {
target[key.toLowerCase()] = value;
} catch (ex) {
// Ignore attempts to add an undeclared attribute to the value, raise all other errors
if (!(ex instanceof TypeError && ex.message.endsWith("not extensible"))) throw ex;
}
// Reassign values to catch missing required sub-attributes
for (let [key, value] of Object.entries(target)) target[key] = value;
} else {
// Go through each value and coerce their sub-attributes
for (let value of (multiValued ? source : [source])) {
target.push(this.coerce(value, direction, true));
}
}
// Return the collection, or the coerced complex value
return (isComplexMultiValue ? target : (!multiValued ? target.pop() : new Proxy(target, {
// Wrap the resulting collection with coercion
set: (target, key, value) => (!!(key in Object.getPrototypeOf([]) && key !== "length" ? false :
(target[key] = (key === "length" ? value : this.coerce(value, direction, true)))))
})));
default:
return source;
}
}
}
}
/**
* SCIM Error Type
* @alias SCIMMY.Types.SCIMError
* @alias SCIMMY.Types.Error
* @see SCIMMY.Messages.ErrorResponse
* @summary
* * Extends the native Error class and provides a way to express errors caused by SCIM protocol, schema conformity, filter expression,
* or other exceptions with details required by the SCIM protocol in [RFC7644§3.12](https://datatracker.ietf.org/doc/html/rfc7644#section-3.12).
*/
class SCIMError extends Error {
/**
* Instantiate a new error with SCIM error details
* @param {Number} status - HTTP status code to be sent with the error
* @param {String} scimType - the SCIM detail error keyword as per [RFC7644§3.12]{@link https://datatracker.ietf.org/doc/html/rfc7644#section-3.12}
* @param {String} message - a human-readable description of what caused the error to occur
* @property {Number} status - HTTP status code to be sent with the error
* @property {String} scimType - the SCIM detail error keyword as per [RFC7644§3.12]{@link https://datatracker.ietf.org/doc/html/rfc7644#section-3.12}
* @property {String} message - a human-readable description of what caused the error to occur
*/
constructor(status, scimType, message) {
super(message);
this.name = "SCIMError";
this.status = status;
this.scimType = scimType;
}
}
/**
* Collection of valid logical operator strings in a filter expression
* @enum SCIMMY.Types.Filter~ValidLogicStrings
* @inner
*/
const operators = ["and", "or", "not"];
/**
* Collection of valid comparison operator strings in a filter expression
* @enum SCIMMY.Types.Filter~ValidComparisonStrings
* @inner
*/
const comparators = ["eq", "ne", "co", "sw", "ew", "gt", "lt", "ge", "le", "pr", "np"];
// Regular expressions that represent filter syntax
const lexicon = [
// White Space, Number Values
/(\s+)/, /([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)(?![\w+-])/,
// Boolean Values, Empty Values, String Values
/(false|true)+/, /(null)+/, /("(?:[^"]|\\.|\n)*")/,
// Logical Groups, Complex Attribute Value Filters
/(\((?:.*?)\))/, /(\[(?:.*?)][.]?)/,
// Logical Operators and Comparators
new RegExp(`(${operators.join("|")})(?=[^a-zA-Z0-9]|$)`),
new RegExp(`(${comparators.join("|")})(?=[^a-zA-Z0-9]|$)`),
// All other "words"
/([-$\w][-$\w._:\/%]*)/
];
// Parsing Pattern Matcher
const patterns = new RegExp(`^(?:${lexicon.map(({source}) => source).join("|")})`, "i");
// 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 = /^(.+?)(\[(?:.*?)])?$/;
// Match ISO 8601 formatted datetime stamps in strings
const isoDate = /^(-?(?:[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])?)?$/;
/**
* SCIM Filter Type
* @alias SCIMMY.Types.Filter
* @summary
* * Parses SCIM [filter expressions](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2) into object representations of the filter expression.
* @description
* This class provides a lexer implementation to tokenise and parse SCIM [filter expression](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2) strings into meaningful object representations.
* It is used to automatically parse `attributes`, `excludedAttributes`, and `filter` expressions in the `{@link SCIMMY.Types.Resource}` class, and by extension, each Resource implementation.
* The SchemaDefinition `{@link SCIMMY.Types.SchemaDefinition#coerce|#coerce()}` method uses instances of this class, typically sourced
* from a Resource instance's `attributes` property, to determine which attributes to include or exclude on coerced resources.
* It is also used for resolving complex multi-valued attribute operations in SCIMMY's {@link SCIMMY.Messages.PatchOp|PatchOp} implementation.
*
* ### Object Representation
* When instantiated with a valid filter expression string, the expression is parsed into an array of objects representing the given expression.
*
* > **Note:**
* > It is also possible to substitute the expression string with an existing or well-formed expression object or set of objects.
* > As such, valid filters can be instantiated using any of the object representations below.
* > When instantiated this way, the `expression` property is dynamically generated from the supplied expression objects.
*
* The properties of each object are directly sourced from attribute names parsed in the expression.
* As the class intentionally has no knowledge of the underlying attribute names associated with a schema,
* the properties of the object are case-sensitive, and will match the case of the attribute name provided in the filter.
* ```js
* // For the filter expressions...
* 'userName eq "Test"', and 'uSerName eq "Test"'
* // ...the object representations are
* [ {userName: ["eq", "Test"]} ], and [ {uSerName: ["eq", "Test"]} ]
* ```
*
* As SCIM attribute names MUST begin with a lower-case letter, they are the exception to this rule,
* and will automatically be cast to lower-case.
* ```js
* // For the filter expressions...
* 'UserName eq "Test"', and 'Name.FamilyName eq "Test"'
* // ...the object representations are
* [ {userName: ["eq", "Test"]} ], and [ {name: {familyName: ["eq", "Test"]}} ]
* ```
*
* #### Logical Operations
* ##### `and`
* For each logical `and` operation in the expression, a new property is added to the object.
* ```js
* // For the filter expression...
* 'userName co "a" and name.formatted sw "Bob" and name.honoraryPrefix eq "Mr"'
* // ...the object representation is
* [ {userName: ["co", "a"], name: {formatted: ["sw", "Bob"], honoraryPrefix: ["eq", "Mr"]}} ]
* ```
*
* When an attribute name is specified multiple times in a logical `and` operation, the expressions are combined into a new array containing each individual expression.
* ```js
* // For the filter expression...
* 'userName sw "A" and userName ew "z"'
* // ...the object representation is
* [ {userName: [["sw", "A"], ["ew", "Z"]]} ]
* ```
*
* ##### `or`
* For each logical `or` operation in the expression, a new object is added to the filter array.
* ```js
* // For the filter expression...
* 'userName eq "Test" or displayName co "Bob"'
* // ...the object representation is
* [
* {userName: ["eq", "Test"]},
* {displayName: ["co", "Bob"]}
* ]
* ```
*
* When the logical `or` operation is combined with the logical `and` operation, the `and` operation takes precedence.
* ```js
* // For the filter expression...
* 'userName eq "Test" or displayName co "Bob" and quota gt 5'
* // ...the object representation is
* [
* {userName: ["eq", "Test"]},
* {displayName: ["co", "Bob"], quota: ["gt", 5]}
* ]
* ```
*
* ##### `not`
* Logical `not` operations in an expression are added to an object property's array of conditions.
* ```js
* // For the filter expression...
* 'not userName eq "Test"'
* // ...the object representation is
* [ {userName: ["not", "eq", "Test"]} ]
* ```
*
* For simplicity, the logical `not` operation is assumed to only apply to the directly following comparison statement in an expression.
* ```js
* // For the filter expression...
* 'userName sw "A" and not userName ew "Z" or displayName co "Bob"'
* // ...the object representation is
* [
* {userName: [["sw", "A"], ["not", "ew", "Z"]]},
* {displayName: ["co", "Bob"]}
* ]
* ```
*
* If needed, logical `not` operations can be applied to multiple comparison statements using grouping operations.
* ```js
* // For the filter expression...
* 'userName sw "A" and not (userName ew "Z" or displayName co "Bob")'
* // ...the object representation is
* [
* {userName: [["sw", "A"], ["not", "ew", "Z"]]},
* {userName: ["sw", "A"], displayName: ["not", "co", "Bob"]}
* ]
* ```
*
* #### Grouping Operations
* As per the order of operations in the SCIM protocol specification, grouping operations are evaluated ahead of any simpler expressions.
*
* In more complex scenarios, expressions can be grouped using `(` and `)` parentheses to change the standard order of operations.
* This is referred to as *precedence grouping*.
* ```js
* // For the filter expression...
* 'userType eq "Employee" and (emails co "example.com" or emails.value co "example.org")'
* // ...the object representation is
* [
* {userType: ["eq", "Employee"], emails: ["co", "example.com"]},
* {userType: ["eq", "Employee"], emails: {value: ["co", "example.org"]}}
* ]
* ```
*
* Grouping operations can also be applied to complex attributes using the `[` and `]` brackets to create filters that target sub-attributes.
* This is referred to as *complex attribute filter grouping*.
* ```js
* // For the filter expression...
* 'emails[type eq "work" and value co "@example.com"] or ims[type eq "xmpp" and value co "@foo.com"]'
* // ...the object representation is
* [
* {emails: {type: ["eq", "work"], value: ["co", "@example.com"]}},
* {ims: {type: ["eq", "xmpp"], value: ["co", "@foo.com"]}}
* ]
* ```
*
* Complex attribute filter grouping can also be used to target sub-attribute values of multi-valued attributes with specific values.
* ```js
* // For the filter expression...
* 'emails[type eq "work" or type eq "home"].values[domain ew "@example.org" or domain ew "@example.com"]'
* // ...the object representation is
* [
* {emails: {type: ["eq", "work"], values: {domain: ["ew", "@example.org"]}}},
* {emails: {type: ["eq", "work"], values: {domain: ["ew", "@example.com"]}}},
* {emails: {type: ["eq", "home"], values: {domain: ["ew", "@example.org"]}}},
* {emails: {type: ["eq", "home"], values: {domain: ["ew", "@example.com"]}}}
* ]
* ```
*
* Precedence and complex attribute filter grouping can also be combined.
* ```js
* // For the filter expression...
* '(userType eq "Employee" or userType eq "Manager") and emails[type eq "work" or (primary eq true and value co "@example.com")].display co "Work"'
* // ...the object representation is
* [
* {userType: ["eq", "Employee"], emails: {type: ["eq", "work"], display: ["co", "Work"]}},
* {userType: ["eq", "Employee"], emails: {primary: ["eq", true], value: ["co", "@example.com"], display: ["co", "Work"]}},
* {userType: ["eq", "Manager"], emails: {type: ["eq", "work"], display: ["co", "Work"]}},
* {userType: ["eq", "Manager"], emails: {primary: ["eq", true], value: ["co", "@example.com"], display: ["co", "Work"]}}
* ]
* ```
*
* ### Other Implementations
* It is not possible to replace internal use of the Filter class inside SCIMMY's {@link SCIMMY.Messages.PatchOp|PatchOp} and `{@link SCIMMY.Types.SchemaDefinition|SchemaDefinition}` implementations.
* Replacing use in the `attributes` property of an instance of `{@link SCIMMY.Types.Resource}`, while technically possible, is not recommended,
* as it may break attribute filtering in the `{@link SCIMMY.Types.SchemaDefinition#coerce|#coerce()}` method of SchemaDefinition instances.
*
* If SCIMMY's filter expression resource matching does not meet your needs, it can be substituted for another implementation
* (e.g. [scim2-parse-filter](https://github.com/thomaspoignant/scim2-parse-filter)) when filtering results within your implementation
* of each resource type's {@link SCIMMY.Types.Resource.ingress|ingress}/{@link SCIMMY.Types.Resource.egress|egress}/{@link SCIMMY.Types.Resource.degress|degress} handler methods.
*
* > **Note:**
* > For more information on implementing handler methods, see the `{@link SCIMMY.Types.Resource~IngressHandler|IngressHandler}/{@link SCIMMY.Types.Resource~EgressHandler|EgressHandler}/{@link SCIMMY.Types.Resource~DegressHandler|DegressHandler}` type definitions of the `SCIMMY.Types.Resource` class.
*
* ```js
* // Import the necessary methods from the other implementation, and for accessing your data source
* import {parse, filter} from "scim2-parse-filter";
* import {users} from "some-database-client";
*
* // Register your ingress/egress/degress handler method
* SCIMMY.Resources.User.egress(async (resource) => {
* // Get the original expression string from the resource's filter property...
* const {expression} = resource.filter;
* // ...and parse/handle it with the other implementation
* const f = filter(parse(expression));
*
* // Retrieve the data from your data source, and filter it as necessary
* return await users.find(/some query returning array/).filter(f);
* });
* ```
*/
class Filter extends Array {
/**
* Make sure derivatives return native arrays
* @internal
* @ignore
*/
static get [Symbol.species]() {
return Array;
}
/**
* The original string that was parsed by the filter, or the stringified representation of filter expression objects
* @type {String}
* @member
*/
expression;
/**
* Instantiate and parse a new SCIM filter string or expression
* @param {String|Object|Object[]} expression - the query string to parse, or an existing filter expression object or set of objects
*/
constructor(expression) {
// See if we're dealing with an expression string
const isString = typeof expression === "string";
// Make sure expression is a string, an object, or an array of objects
if (!isString && !(Array.isArray(expression) ? expression : [expression]).every(e => !!e && Object.getPrototyp