-
-
Notifications
You must be signed in to change notification settings - Fork 15
/
scimPatch.ts
553 lines (488 loc) · 20.7 KB
/
scimPatch.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
import {
ScimError,
InvalidScimPatch,
InvalidScimPatchOp,
NoPathInScimPatchOp,
InvalidScimPatchRequest,
NoTarget,
RemoveValueNestedArrayNotSupported,
RemoveValueNotArray,
InvalidScimRemoveValue,
FilterOnEmptyArray,
FilterArrayTargetNotFound,
InvalidRemoveOpPath
} from './errors/scimErrors';
import {
ScimPatchSchema,
ScimId,
ScimSchema,
ScimPatchOperation,
ScimPatchRemoveOperation,
ScimPatchAddReplaceOperation,
ScimPatch,
ScimResource,
ScimMeta,
NavigateOptions,
FilterWithQueryOptions,
ScimPatchOptions,
} from './types/types';
import {parse, filter} from 'scim2-parse-filter';
import deepEqual = require('fast-deep-equal');
/*
* Export types
*/
export {
ScimPatchSchema,
ScimId,
ScimSchema,
ScimPatchOperation,
ScimPatchRemoveOperation,
ScimPatchAddReplaceOperation,
ScimPatch,
ScimResource,
ScimMeta,
ScimError,
InvalidScimPatch,
InvalidScimPatchOp,
NoPathInScimPatchOp,
InvalidScimPatchRequest,
NoTarget,
RemoveValueNestedArrayNotSupported,
RemoveValueNotArray,
InvalidScimRemoveValue
};
/*
* This file implement the SCIM PATCH specification.
* RFC : https://tools.ietf.org/html/rfc7644#section-3.5.2
* It allow to apply some patch on an existing SCIM resource.
*/
// Regex to check if this is search into array request.
const IS_ARRAY_SEARCH = /(\[|\])/;
// Regex to extract key and search request (ex: emails[primary eq true).
const ARRAY_SEARCH = /^(.+)\[(.+)\]$/;
// Split path on periods
const SPLIT_PERIOD = /(?!\B"[^[]*)\.(?![^\]]*"\B)/g;
// Valid patch operation, value needs to be in lowercase here.
const AUTHORIZED_OPERATION = ['remove', 'add', 'replace'];
const CORE_SCHEMA_USER = 'urn:ietf:params:scim:schemas:core:2.0:User';
const CORE_SCHEMA_GROUP = 'urn:ietf:params:scim:schemas:core:2.0:Group';
export const PATCH_OPERATION_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:PatchOp';
/*
* PatchBodyValidation validate if the request body of the SCIM Patch is valid.
* If the body is not valid the function throw an error.
* @param body data from the patch request.
* @throws {InvalidScimPatchRequest} if one operation is not valid.
* @throws {NoPathInScimPatchOp} if one operation is a remove with no path.
*/
export function patchBodyValidation(body: ScimPatch): void {
if (!body.schemas || !body.schemas.includes(PATCH_OPERATION_SCHEMA))
throw new InvalidScimPatchRequest('Missing schemas.');
if (!Array.isArray(body.Operations))
throw new InvalidScimPatchRequest('Operations should be an array.');
if (!body.Operations || body.Operations.length <= 0)
throw new InvalidScimPatchRequest('Missing operations.');
body.Operations.forEach(validatePatchOperation);
}
/*
* This method apply patch operations on a SCIM Resource.
* @param scimResource The initial resource
* @param patchOperations An array of SCIM patch operations we want to apply on the scimResource object.
* @param options Options to customize some behaviour of scimPatch
* @return the scimResource patched.
* @throws {InvalidScimPatchOp} if the patch could not happen.
*/
export function scimPatch<T extends ScimResource>(scimResource: T, patchOperations: Array<ScimPatchOperation>,
options: ScimPatchOptions = {mutateDocument: true, treatMissingAsAdd: true}): T {
if (!options.mutateDocument) {
// Deeply clone the object.
// https://jsperf.com/deep-copy-vs-json-stringify-json-parse/25 (recursiveDeepCopy)
scimResource = JSON.parse(JSON.stringify(scimResource));
}
return patchOperations.reduce((patchedResource, patch) => {
switch (patch.op) {
case 'remove':
case 'Remove':
return applyRemoveOperation(patchedResource, patch);
case 'add':
case 'Add':
case 'replace':
case 'Replace':
return applyAddOrReplaceOperation(patchedResource, patch, !!options.treatMissingAsAdd);
default:
throw new InvalidScimPatchRequest(`Operator is invalid for SCIM patch request. ${patch}`);
}
}, scimResource);
}
/*
* validateOperation is validating that the SCIM Patch Operation follow the RFC.
* If not, the function throw an Error.
* @param operation The SCIM operation we want to check.
* @throws {InvalidScimPatchRequest} if the operation is not valid.
* @throws {NoPathInScimPatchOp} if the operation is a remove with no path.
*/
function validatePatchOperation(operation: ScimPatchOperation): void {
if (typeof operation.op !== 'string' || !isValidOperation(operation.op))
throw new InvalidScimPatchRequest(`Invalid op "${operation.op}" in the request.`);
if (operation.op === 'remove' && !operation.path)
throw new NoPathInScimPatchOp();
if (isAddOperation(operation.op) && !('value' in operation))
throw new InvalidScimPatchRequest(`The operation ${operation.op} MUST contain a "value" member whose content specifies the value to be added`);
if (operation.path && typeof operation.path !== 'string')
throw new InvalidScimPatchRequest('Path is supposed to be a string');
}
function resolvePaths(path: string): string[] {
const uriIndex = path.lastIndexOf(':');
if (uriIndex < 0) {
// No schema prefix - this is a core schema path
return path.split(SPLIT_PERIOD);
}
const schemaUri = path.substring(0, uriIndex);
const paths = path.substring(uriIndex +1).split(SPLIT_PERIOD);
switch(schemaUri) {
case CORE_SCHEMA_GROUP:
case CORE_SCHEMA_USER:
// Ignore core schema URIs in paths. These are allowed but not part of object keys
break;
default:
// Assume any other provided schema URI is an extension
paths.unshift(schemaUri);
break;
}
return paths;
}
function applyRemoveOperation<T extends ScimResource>(scimResource: T, patch: ScimPatchRemoveOperation): T {
// We manipulate the object directly without knowing his property, that's why we use any.
let resources_scoped: Record<string, any>[];
validatePatchOperation(patch);
// Path is supposed to be set, there are a validation in the validateOperation function.
const paths = resolvePaths(patch.path);
try {
resources_scoped = navigate(scimResource, paths, {isRemoveOp: true});
} catch (error) {
if (error instanceof InvalidRemoveOpPath) {
return scimResource;
}
throw error;
}
// Dealing with the last element of the path.
const lastSubPath = paths[paths.length - 1];
if (!IS_ARRAY_SEARCH.test(lastSubPath)) {
// This is a mono valued property
for (const resource of resources_scoped) {
if (!patch.value) {
// No value in the remove operation, we delete it.
delete resource[lastSubPath];
} else {
// Value in the remove operation, we remove the children by value.
resource[lastSubPath] = removeWithPatchValue(resource[lastSubPath], patch.value);
}
}
return scimResource;
}
for (const resource of resources_scoped) {
// The last element is an Array request.
const {attrName, valuePath, array} = extractArray(lastSubPath, resource);
// We keep only items who don't match the query if supplied.
resource[attrName] = filterWithQuery<any>(array, valuePath, {excludeIfMatchFilter: true});
// If the complex multi-valued attribute has no remaining records, the attribute SHALL be considered unassigned.
if (resource[attrName].length === 0)
delete resource[attrName];
}
return scimResource;
}
function applyAddOrReplaceOperation<T extends ScimResource>(scimResource: T, patch: ScimPatchAddReplaceOperation, treatMissingAsAdd: boolean): T {
// We manipulate the object directly without knowing his property, that's why we use any.
// let resource: Record<string, any> = scimResource;
let resources_scoped: Record<string, any>[];
validatePatchOperation(patch);
if (!patch.path)
return addOrReplaceAttribute(scimResource, patch);
// We navigate till the second to last of the path.
const paths = resolvePaths(patch.path);
const lastSubPath = paths[paths.length - 1];
try {
resources_scoped = navigate(scimResource, paths);
} catch(e) {
if (e instanceof FilterOnEmptyArray || e instanceof FilterArrayTargetNotFound) {
const resource: Record<string, any> = e.schema;
// check issue https://github.com/thomaspoignant/scim-patch/issues/42 to see why we should add this
const parsedPath = parse(e.valuePath);
if (isAddOperation(patch.op) &&
"compValue" in parsedPath &&
parsedPath.compValue !== undefined &&
parsedPath.op === "eq"
) {
const result: any = {};
result[parsedPath.attrPath] = parsedPath.compValue;
result[lastSubPath] = addOrReplaceAttribute(resource, patch, true);
resource[e.attrName] = [...(resource[e.attrName] ?? []), result];
return scimResource;
} else if (
treatMissingAsAdd &&
isReplaceOperation(patch.op) &&
"compValue" in parsedPath &&
parsedPath.compValue !== undefined &&
parsedPath.op === "eq"
) {
// If the target location path specifies an attribute that does not
// exist, the service provider SHALL treat the operation as an "add".
return applyAddOrReplaceOperation(
scimResource,
{ ...patch, op: "add" },
false
);
}
throw new NoTarget(patch.path);
}
throw e;
}
if (!IS_ARRAY_SEARCH.test(lastSubPath)) {
for (const resource of resources_scoped) {
resource[lastSubPath] = addOrReplaceAttribute(resource[lastSubPath], patch);
}
return scimResource;
}
// The last element is an Array request.
for (const resource of resources_scoped) {
const {valuePath, array} = extractArray(lastSubPath, resource);
// Get the list of items who are successful for the search query.
const matchFilter = filterWithQuery<any>(array, valuePath);
// If the target location is a multi-valued attribute for which a value selection filter ("valuePath") has been
// supplied and no record match was made, the service provider SHALL indicate failure by returning HTTP status
// code 400 and a "scimType" error code of "noTarget".
if (isReplaceOperation(patch.op) && matchFilter.length === 0) {
throw new NoTarget(patch.path);
}
for (let i = 0; i < array.length; i++) {
// We are sure to find at least one index because matchFilter comes from array.
if(matchFilter.includes(array[i])){
array[i] = addOrReplaceAttribute(array[i], patch);
}
}
}
return scimResource;
}
/**
* extractArray extract the valuePath (ex: email[primary eq true]) of a subPath
* @param subPath The key we want to extract.
* @param schema The object which is supposed to contains the array.
* @return an array with the array name and the filter path.
*/
function extractArray(subPath: string, schema: any): ScimSearchQuery {
// We extract the key of the table and what is inside [].
const matchRequest = subPath.match(ARRAY_SEARCH);
if (!matchRequest)
throw new InvalidScimPatchOp(`This part of the path ${subPath} is invalid for SCIM patch request.`);
const [, attrName, valuePath] = matchRequest;
const element = schema[attrName];
if (!Array.isArray(element))
throw new FilterOnEmptyArray('Impossible to search on a mono valued attribute.', attrName, valuePath);
return new ScimSearchQuery(attrName, valuePath, element);
}
/**
* navigate allow to get the sub object who want to edit with the patch operation.
* @param inputSchema the initial ScimResource
* @param paths an Array who contains the path of the sub object
* @param options options used while calling navigate
* @return the parent object of the element we want to edit
*/
function navigate(inputSchema: any, paths: string[], options: NavigateOptions = {}): Record<string, unknown>[] {
let schemas: any[] = [inputSchema];
for (let i = 0; i < paths.length - 1; i++) {
const subPath = paths[i];
// We check if the element is an array with query (ex: emails[primary eq true).
if (IS_ARRAY_SEARCH.test(subPath)) {
schemas = schemas.flatMap((schema)=>{
try {
const {attrName, valuePath, array} = extractArray(subPath, schema);
// Get the item who is successful for the search query.
const matchFilter = filterWithQuery<any>(array, valuePath);
if (matchFilter.length === 0) {
throw new FilterArrayTargetNotFound('A matching array entry was not found using the supplied filter.', attrName, valuePath, schema);
}
return matchFilter;
} catch (error) {
/*
FIXME In some edge cases, not fully compliant with SCIM, this can prematurely throw en error
For example if we have a structure with multiValued complex
attribute inside of a multiValued complex attribute, in
this case this throw even if only a "branch" of the search fail.
*/
if(error instanceof FilterOnEmptyArray){
error.schema = schema;
}
throw error;
}
});
} else {
schemas = schemas.flatMap((schema)=>{
if (!schema[subPath] && options.isRemoveOp)
throw new InvalidRemoveOpPath();
return schema[subPath] || (schema[subPath] = {});
});
}
}
return schemas;
}
/**
* Add or Replace a property in the ScimResource
* @param property The property we want to replace
* @param patch The patch operation
* @param multiValuedPathFilter True if thi is a multivalued path filter query
* @return the patched property
*/
function addOrReplaceAttribute(property: any, patch: ScimPatchAddReplaceOperation, multiValuedPathFilter?: boolean): any {
if (Array.isArray(property)) {
if (Array.isArray(patch.value)) {
// if we're adding an array, we need to remove duplicated values from existing array
if (isAddOperation(patch.op)) {
const valuesToAdd = patch.value.filter(item => !deepIncludes(property, item));
return property.concat(valuesToAdd);
}
// else this is a replace operation
return patch.value;
}
if(isReplaceOperation(patch.op)){
return property.map(
(it) => addOrReplaceAttribute(it,patch,multiValuedPathFilter)
);
}
const a = property;
if (!deepIncludes(a, patch.value))
a.push(patch.value);
return a;
}
if (property !== null && typeof property === 'object') {
return addOrReplaceObjectAttribute(property, patch, multiValuedPathFilter);
}
// If the target location specifies a single-valued attribute, the existing value is replaced.
return patch.value;
}
/**
* addOrReplaceObjectAttribute will add an attribute if it is an object
* @param property The property we want to replace
* @param patch The patch operation
* @param multiValuedPathFilter True if thi is a multivalued path filter query
*/
function addOrReplaceObjectAttribute(property: any, patch: ScimPatchAddReplaceOperation, multiValuedPathFilter?: boolean): any {
if (typeof patch.value !== 'object') {
if (isAddOperation(patch.op) && !multiValuedPathFilter)
throw new InvalidScimPatchOp('Invalid patch query.');
return patch.value;
}
// fix https://github.com/thomaspoignant/scim-patch/issues/489
// when trying to insert an empty object, we should directly insert it without merging.
if (Object.keys(patch.value).length === 0) {
return {};
}
// We add all the patch values to the property object.
for (const [key, value] of Object.entries(patch.value)) {
assign(property, resolvePaths(key), value, patch.op);
}
return property;
}
/**
* assign is taking an array of key and add the associated nested object.
* @param obj the object where we should the key
* @param keyPath an array which represent the path of the nested object
* @param value value to assign
* @param op patch operation
*/
function assign(obj:any, keyPath:Array<string>, value:any, op: string) {
const lastKeyIndex = keyPath.length-1;
for (let i = 0; i < lastKeyIndex; ++ i) {
const key = keyPath[i];
if (!(key in obj)){
obj[key] = {};
}
obj = obj[key];
}
// If the attribute is an array and the operation is "add",
// then the value should be added to the array
const attribute = obj[keyPath[lastKeyIndex]];
if (isAddOperation(op) && Array.isArray(attribute)) {
// If the value is also an array, append all values of the array to the attribute
if (Array.isArray(value)) {
obj[keyPath[lastKeyIndex]] = [...attribute, ...value];
return;
}
// If value is not an array, simply append it as a whole to end of attribute
obj[keyPath[lastKeyIndex]] = [...attribute, value];
return;
}
obj[keyPath[lastKeyIndex]] = value;
}
/**
* Return the items in the array who match the filter.
* @param arr the collection where we are searching.
* @param querySearch the search request.
* @param options options used while calling filterWithQuery
* @return an array who contains the search results.
*/
function filterWithQuery<T>(arr: Array<T>, querySearch: string,
options: FilterWithQueryOptions = ({} as FilterWithQueryOptions)): Array<T> {
try {
const f = filter(parse(querySearch));
return arr.filter(e => options.excludeIfMatchFilter ? !f(e) : f(e));
} catch (error) {
throw new InvalidScimPatchOp(`${error}`);
}
}
/**
* Return the array without items supplied in .
* @param arr the collection where we are searching.
* @param itemsToRemove array with items to remove from original.
* @return an array which contains the search results.
*/
function removeWithPatchValue<T>(arr: Array<T>, itemsToRemove: Array<T> | Record<string, any> | string | number): T[] {
if (!Array.isArray(arr))
throw new RemoveValueNotArray();
// patch value is a single item, we remove from the array all the similar items.
if (!Array.isArray(itemsToRemove))
return arr.filter(item => !deepEqual(itemsToRemove, item));
// Sometimes the patch value is an array (this is how it works with one-login, ex: [{"test":true}])
// We iterate on all the values in the array to delete them all.
itemsToRemove.forEach(toRemove => {
if (Array.isArray(toRemove))
throw new RemoveValueNestedArrayNotSupported();
arr = arr.filter(item => !deepEqual(toRemove, item));
});
return arr;
}
/**
* deepIncludes has similar behaviour as Array.prototype.includes,
* just that instead of === for equality check, it uses deepEqual library
* @param array the array on which inclusion check has to be performed
* @param item the item whose inclusion has to be checked
* @returns true if the item is present, else false
*/
function deepIncludes(array: any[], item: any): boolean {
return array.some(el => deepEqual(item, el));
}
function isValidOperation(operation: string): boolean {
return AUTHORIZED_OPERATION.includes(operation.toLowerCase());
}
/**
* isAddOperation check if the operation is an ADD
* @param operation the name of the SCIM Patch operation
* @return true if this is an add operation
*/
function isAddOperation(operation: string): boolean {
return operation !== undefined && operation.toLowerCase() === 'add';
}
/**
* isReplaceOperation check if the operation is an REPACE
* @param operation the name of the SCIM Patch operation
* @return true if this is a replace operation
*/
function isReplaceOperation(operation: string): boolean {
return operation !== undefined && operation.toLowerCase() === 'replace';
}
class ScimSearchQuery {
constructor(
readonly attrName: string,
readonly valuePath: string,
readonly array: Array<any>
) {
}
}