Skip to content

Commit 68fc209

Browse files
committed
feat: add mutability checks and complex attribute merge for patch ops
Validate readOnly and immutable attribute mutability per RFC 7644 Section 3.5.2 before applying add, remove, and replace operations. Implement sub-attribute merging for replace on singular complex attributes (Section 3.5.2.3) so unspecified sub-attributes are left unchanged. Return noTarget error for remove without a path (Section 3.5.2.2).
1 parent 2bd670d commit 68fc209

File tree

2 files changed

+333
-6
lines changed

2 files changed

+333
-6
lines changed

patch_apply.go

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,33 @@ func applyToMatching(list []interface{}, t *valueExprTarget, value interface{},
5555
return result, matched, nil
5656
}
5757

58+
func attrFromTarget(target interface{}) schema.CoreAttribute {
59+
switch t := target.(type) {
60+
case *attributeTarget:
61+
return t.attr
62+
case *subAttributeTarget:
63+
return t.attr
64+
case *valueExprTarget:
65+
return t.attr
66+
}
67+
panic("unknown target type")
68+
}
69+
70+
// checkMutability validates that the operation is compatible with the
71+
// attribute's mutability. Returns a mutability ScimError if not.
72+
func checkMutability(op string, attr schema.CoreAttribute, exists bool) error {
73+
switch attr.Mutability() {
74+
case "readOnly":
75+
return scimErrors.ScimErrorMutability
76+
case "immutable":
77+
if op == PatchOperationAdd && !exists {
78+
return nil
79+
}
80+
return scimErrors.ScimErrorMutability
81+
}
82+
return nil
83+
}
84+
5885
func copyMap(m map[string]interface{}) map[string]interface{} {
5986
cp := make(map[string]interface{}, len(m))
6087
for k, v := range m {
@@ -230,6 +257,11 @@ func applyAdd(attrs ResourceAttributes, op PatchOperation, s schema.Schema, exte
230257
return nil, err
231258
}
232259

260+
_, exists := attrs[attrName]
261+
if err := checkMutability(op.Op, attrFromTarget(target), exists); err != nil {
262+
return nil, err
263+
}
264+
233265
switch t := target.(type) {
234266
case *attributeTarget:
235267
existing, exists := attrs[attrName]
@@ -330,14 +362,19 @@ func applyOperation(attrs ResourceAttributes, op PatchOperation, s schema.Schema
330362

331363
func applyRemove(attrs ResourceAttributes, op PatchOperation, s schema.Schema, extensions []schema.Schema) (ResourceAttributes, error) {
332364
if op.Path == nil {
333-
return nil, fmt.Errorf("path is required for remove operations")
365+
return nil, scimErrors.ScimErrorNoTarget
334366
}
335367

336368
attrName, target, err := resolveTarget(op.Path, s, extensions)
337369
if err != nil {
338370
return nil, err
339371
}
340372

373+
_, exists := attrs[attrName]
374+
if err := checkMutability(op.Op, attrFromTarget(target), exists); err != nil {
375+
return nil, err
376+
}
377+
341378
switch t := target.(type) {
342379
case *attributeTarget:
343380
delete(attrs, attrName)
@@ -389,8 +426,38 @@ func applyReplace(attrs ResourceAttributes, op PatchOperation, s schema.Schema,
389426
return nil, err
390427
}
391428

429+
_, exists := attrs[attrName]
430+
if err := checkMutability(op.Op, attrFromTarget(target), exists); err != nil {
431+
return nil, err
432+
}
433+
392434
switch t := target.(type) {
393435
case *attributeTarget:
436+
// RFC 7644 Section 3.5.2.3: if the target location path specifies
437+
// an attribute that does not exist, the service provider SHALL
438+
// treat the operation as an "add".
439+
if _, exists := attrs[attrName]; !exists {
440+
return applyAdd(attrs, op, s, extensions)
441+
}
442+
// RFC 7644 Section 3.5.2.3: "If the target location specifies a
443+
// complex attribute, a set of sub-attributes SHALL be specified in
444+
// the 'value' parameter, which replaces any existing values or adds
445+
// where an attribute did not previously exist. Sub-attributes that
446+
// are not specified in the 'value' parameter are left unchanged."
447+
if t.attr.HasSubAttributes() && !t.attr.MultiValued() {
448+
existingMap, ok := attrs[attrName].(map[string]interface{})
449+
if ok {
450+
valueMap, ok := op.Value.(map[string]interface{})
451+
if ok {
452+
merged := copyMap(existingMap)
453+
for k, v := range valueMap {
454+
merged[k] = v
455+
}
456+
attrs[attrName] = merged
457+
return attrs, nil
458+
}
459+
}
460+
}
394461
attrs[attrName] = op.Value
395462
case *subAttributeTarget:
396463
existing, exists := attrs[attrName]
@@ -411,7 +478,8 @@ func applyReplace(attrs ResourceAttributes, op PatchOperation, s schema.Schema,
411478
// RFC 7644 Section 3.5.2.3: if the target location is a multi-valued
412479
// attribute for which a value selection filter ("valuePath") has been
413480
// supplied and no record match was made, the service provider SHALL
414-
// return a 400 error with a scimType of noTarget.
481+
// indicate failure by returning HTTP status code 400 and a "scimType"
482+
// error code of "noTarget".
415483
existing, exists := attrs[attrName]
416484
if !exists {
417485
return nil, scimErrors.ScimErrorNoTarget

0 commit comments

Comments
 (0)