Skip to content

Commit 71f2752

Browse files
pi1814ory-bot
authored andcommitted
fix: scim remove op with filters
GitOrigin-RevId: e1c1afb9b2a98f1e5347e3b97868f9f326b37f09
1 parent 8e6f103 commit 71f2752

3 files changed

Lines changed: 189 additions & 3 deletions

File tree

npm/src/directory-sync/scim/utils.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ export const parseUserPatchRequest = (operation: UserPatchOperation) => {
9696
if (op === 'remove' && path) {
9797
rawAttributes[path] = REMOVE_SENTINEL;
9898

99+
// Propagate removal to standard user model attributes
100+
if (path in attributesMap) {
101+
attributes[attributesMap[path]] = '';
102+
}
103+
99104
return {
100105
attributes,
101106
rawAttributes,
@@ -156,11 +161,17 @@ export const extractStandardUserAttributes = (body: any) => {
156161
return userAttributes;
157162
};
158163

164+
// Match SCIM filter paths with a sub-attribute: `phoneNumbers[type eq "work"].value`
165+
const SCIM_FILTER_SUB_ATTR_RE = /^(\w+)\[(\w+)\s+eq\s+"([^"]+)"\]\.(\w+)$/;
166+
167+
// Match SCIM filter paths without a sub-attribute: `phoneNumbers[type eq "work"]`
168+
const SCIM_FILTER_RE = /^(\w+)\[(\w+)\s+eq\s+"([^"]+)"\]$/;
169+
159170
// Resolve a SCIM filter path like `phoneNumbers[type eq "work"].value` to a
160171
// lodash-compatible path like `["phoneNumbers", 0, "value"]`. Returns false for
161172
// non-filter paths so the caller can fall back to plain _.set.
162173
const applySCIMFilterUpdate = (raw: any, path: string, value: any): boolean => {
163-
const match = path.match(/^(\w+)\[(\w+)\s+eq\s+"([^"]+)"\]\.(\w+)$/);
174+
const match = path.match(SCIM_FILTER_SUB_ATTR_RE);
164175
if (!match) {
165176
return false;
166177
}
@@ -188,6 +199,49 @@ const applySCIMFilterUpdate = (raw: any, path: string, value: any): boolean => {
188199
return true;
189200
};
190201

202+
// Remove matching entries from a multi-valued attribute using a SCIM filter path.
203+
// Handles two forms:
204+
// - `attr[filter]` → remove entire matching array entries
205+
// - `attr[filter].subAttr` → unset only the sub-attribute from matching entries
206+
// Returns false for non-filter paths so the caller can fall back to _.unset.
207+
const removeSCIMFilterPath = (raw: any, path: string): boolean => {
208+
// Try sub-attribute form first: phoneNumbers[type eq "work"].value
209+
const subMatch = path.match(SCIM_FILTER_SUB_ATTR_RE);
210+
if (subMatch) {
211+
const [, attribute, filterAttr, filterValue, subAttribute] = subMatch;
212+
const arr = _.get(raw, attribute);
213+
if (!Array.isArray(arr)) {
214+
return true; // Nothing to remove — already absent
215+
}
216+
217+
for (const el of arr) {
218+
if (el[filterAttr] === filterValue) {
219+
delete el[subAttribute];
220+
}
221+
}
222+
return true;
223+
}
224+
225+
// Try whole-entry form: phoneNumbers[type eq "work"]
226+
const match = path.match(SCIM_FILTER_RE);
227+
if (match) {
228+
const [, attribute, filterAttr, filterValue] = match;
229+
const arr = _.get(raw, attribute);
230+
if (!Array.isArray(arr)) {
231+
return true; // Nothing to remove — already absent
232+
}
233+
234+
_.set(
235+
raw,
236+
attribute,
237+
arr.filter((el: any) => el[filterAttr] !== filterValue)
238+
);
239+
return true;
240+
}
241+
242+
return false;
243+
};
244+
191245
// Update raw user attributes
192246
export const updateRawUserAttributes = (raw, attributes) => {
193247
const keys = Object.keys(attributes);
@@ -210,11 +264,16 @@ export const updateRawUserAttributes = (raw, attributes) => {
210264
// Path format: "urn:...:User:attribute" — remove the attribute from the schema object.
211265
const i = key.lastIndexOf(':');
212266
const urn = key.substring(0, i);
267+
const attrName = key.substring(i + 1);
213268
if (raw[urn] && typeof raw[urn] === 'object') {
214-
delete raw[urn][key.substring(i + 1)];
269+
// If the attribute contains a SCIM filter (e.g. "roles[value eq ...]"),
270+
// delegate to removeSCIMFilterPath scoped to the schema sub-object.
271+
if (!removeSCIMFilterPath(raw[urn], attrName)) {
272+
delete raw[urn][attrName];
273+
}
215274
}
216275
}
217-
} else {
276+
} else if (!removeSCIMFilterPath(raw, key)) {
218277
_.unset(raw, key);
219278
}
220279
continue;

npm/test/dsync/data/user-requests.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,30 @@ const requests = {
259259
};
260260
},
261261

262+
// Remove standard mapped attributes using op: "remove" (name.givenName, name.familyName)
263+
removeStandardMappedAttributes: (directory: Directory, userId: string): DirectorySyncRequest => {
264+
return {
265+
method: 'PATCH',
266+
body: {
267+
Operations: [
268+
{
269+
op: 'remove',
270+
path: 'name.givenName',
271+
},
272+
{
273+
op: 'remove',
274+
path: 'name.familyName',
275+
},
276+
],
277+
},
278+
directoryId: directory.id,
279+
resourceType: 'users',
280+
resourceId: userId,
281+
apiSecret: directory.scim.secret,
282+
query: {},
283+
};
284+
},
285+
262286
// Remove standard attribute using op: "remove" with no value
263287
removeTitle: (directory: Directory, userId: string): DirectorySyncRequest => {
264288
return {
@@ -433,6 +457,53 @@ const requests = {
433457
};
434458
},
435459

460+
// Entra: set extension multi-valued attribute via URN path
461+
entraSetExtensionMultiValued: (directory: Directory, userId: string): DirectorySyncRequest => {
462+
return {
463+
method: 'PATCH',
464+
body: {
465+
Operations: [
466+
{
467+
op: 'replace',
468+
value: {
469+
'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User': {
470+
roles: [
471+
{ value: 'admin-role-id', display: 'Admin' },
472+
{ value: 'user-role-id', display: 'User' },
473+
],
474+
},
475+
},
476+
},
477+
],
478+
},
479+
directoryId: directory.id,
480+
resourceType: 'users',
481+
resourceId: userId,
482+
apiSecret: directory.scim.secret,
483+
query: {},
484+
};
485+
},
486+
487+
// Entra: remove a specific value from extension multi-valued attribute via URN + filter path
488+
entraRemoveExtensionFilterPath: (directory: Directory, userId: string): DirectorySyncRequest => {
489+
return {
490+
method: 'PATCH',
491+
body: {
492+
Operations: [
493+
{
494+
op: 'remove',
495+
path: 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:roles[value eq "admin-role-id"]',
496+
},
497+
],
498+
},
499+
directoryId: directory.id,
500+
resourceType: 'users',
501+
resourceId: userId,
502+
apiSecret: directory.scim.secret,
503+
query: {},
504+
};
505+
},
506+
436507
// Arbitrary attribute names with SCIM filter paths (e.g. ims, photos)
437508
arbitraryFilterPaths: (directory: Directory, userId: string): DirectorySyncRequest => {
438509
return {

npm/test/dsync/users.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,29 @@ tap.test('Directory users /', async (t) => {
186186
t.notOk(data.title, 'title should be removed');
187187
});
188188

189+
t.test('Should propagate remove op for standard mapped attributes to user model', async (t) => {
190+
// Verify user has name fields from initial creation
191+
const { data: initialUser } = await directorySync.requests.handle(
192+
requests.getById(directory, createdUser.id)
193+
);
194+
t.equal(initialUser.name.givenName, 'Jackson', 'user should have givenName initially');
195+
t.equal(initialUser.name.familyName, 'M', 'user should have familyName initially');
196+
197+
// Remove name.givenName and name.familyName using op: "remove"
198+
const { status, data } = await directorySync.requests.handle(
199+
requests.removeStandardMappedAttributes(directory, createdUser.id)
200+
);
201+
202+
t.equal(status, 200);
203+
t.notOk(data.name?.givenName, 'givenName should be removed from raw');
204+
t.notOk(data.name?.familyName, 'familyName should be removed from raw');
205+
206+
// Verify the user model fields are cleared via the internal user record
207+
const user = await directorySync.users.get(createdUser.id);
208+
t.equal(user.data?.first_name, '', 'first_name should be cleared on user model');
209+
t.equal(user.data?.last_name, '', 'last_name should be cleared on user model');
210+
});
211+
189212
t.test('Entra: should set and clear extension attributes via no-path object format', async (t) => {
190213
// Set extension attributes (Entra no-path format with nested schema object)
191214
const { status: setStatus, data: setData } = await directorySync.requests.handle(
@@ -376,6 +399,39 @@ tap.test('Directory users /', async (t) => {
376399
t.equal(workAddress.country, 'US');
377400
});
378401

402+
t.test(
403+
'Entra: should remove specific value from extension multi-valued attribute via URN filter path',
404+
async (t) => {
405+
const extKey = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User';
406+
407+
// First, set a multi-valued extension attribute with two roles
408+
const { status: setStatus, data: setData } = await directorySync.requests.handle(
409+
requests.entraSetExtensionMultiValued(directory, createdUser.id)
410+
);
411+
412+
t.equal(setStatus, 200);
413+
t.ok(setData[extKey], 'extension schema key should exist');
414+
t.equal(setData[extKey].roles.length, 2, 'should have two roles');
415+
416+
// Remove only the admin role using URN + filter path
417+
const { status, data } = await directorySync.requests.handle(
418+
requests.entraRemoveExtensionFilterPath(directory, createdUser.id)
419+
);
420+
421+
t.equal(status, 200);
422+
t.ok(data[extKey], 'extension schema key should still exist');
423+
t.equal(data[extKey].roles.length, 1, 'should have one role remaining');
424+
t.equal(data[extKey].roles[0].value, 'user-role-id', 'only the user role should remain');
425+
426+
// Verify removal persists
427+
const { data: fetched } = await directorySync.requests.handle(
428+
requests.getById(directory, createdUser.id)
429+
);
430+
t.equal(fetched[extKey].roles.length, 1, 'removal should persist after re-fetch');
431+
t.equal(fetched[extKey].roles[0].value, 'user-role-id', 'correct role should persist');
432+
}
433+
);
434+
379435
t.test('Should handle SCIM filter paths for arbitrary attribute names', async (t) => {
380436
const { status, data } = await directorySync.requests.handle(
381437
requests.arbitraryFilterPaths(directory, createdUser.id)

0 commit comments

Comments
 (0)