scimitar 1.10.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +23 -98
  3. data/app/controllers/scimitar/application_controller.rb +13 -41
  4. data/app/controllers/scimitar/resource_types_controller.rb +2 -0
  5. data/app/controllers/scimitar/resources_controller.rb +2 -0
  6. data/app/controllers/scimitar/schemas_controller.rb +3 -366
  7. data/app/controllers/scimitar/service_provider_configurations_controller.rb +1 -0
  8. data/app/models/scimitar/complex_types/address.rb +6 -0
  9. data/app/models/scimitar/engine_configuration.rb +5 -15
  10. data/app/models/scimitar/error_response.rb +0 -12
  11. data/app/models/scimitar/lists/query_parser.rb +13 -113
  12. data/app/models/scimitar/resource_invalid_error.rb +1 -1
  13. data/app/models/scimitar/resources/base.rb +9 -53
  14. data/app/models/scimitar/resources/mixin.rb +59 -646
  15. data/app/models/scimitar/schema/address.rb +0 -1
  16. data/app/models/scimitar/schema/attribute.rb +5 -14
  17. data/app/models/scimitar/schema/base.rb +1 -1
  18. data/app/models/scimitar/schema/name.rb +2 -2
  19. data/app/models/scimitar/schema/user.rb +10 -10
  20. data/app/models/scimitar/schema/vdtp.rb +1 -1
  21. data/app/models/scimitar/service_provider_configuration.rb +3 -14
  22. data/config/initializers/scimitar.rb +3 -69
  23. data/lib/scimitar/engine.rb +12 -57
  24. data/lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb +10 -140
  25. data/lib/scimitar/version.rb +2 -2
  26. data/lib/scimitar.rb +2 -7
  27. data/spec/apps/dummy/app/controllers/mock_groups_controller.rb +1 -1
  28. data/spec/apps/dummy/app/models/mock_group.rb +1 -1
  29. data/spec/apps/dummy/app/models/mock_user.rb +9 -52
  30. data/spec/apps/dummy/config/application.rb +1 -0
  31. data/spec/apps/dummy/config/environments/test.rb +28 -5
  32. data/spec/apps/dummy/config/initializers/scimitar.rb +10 -90
  33. data/spec/apps/dummy/config/routes.rb +7 -28
  34. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +1 -11
  35. data/spec/apps/dummy/db/migrate/20210308044214_create_join_table_mock_groups_mock_users.rb +3 -8
  36. data/spec/apps/dummy/db/schema.rb +4 -12
  37. data/spec/controllers/scimitar/application_controller_spec.rb +3 -126
  38. data/spec/controllers/scimitar/resource_types_controller_spec.rb +2 -2
  39. data/spec/controllers/scimitar/schemas_controller_spec.rb +48 -344
  40. data/spec/models/scimitar/complex_types/address_spec.rb +4 -3
  41. data/spec/models/scimitar/complex_types/email_spec.rb +2 -0
  42. data/spec/models/scimitar/lists/query_parser_spec.rb +9 -146
  43. data/spec/models/scimitar/resources/base_spec.rb +71 -217
  44. data/spec/models/scimitar/resources/base_validation_spec.rb +5 -43
  45. data/spec/models/scimitar/resources/mixin_spec.rb +129 -1508
  46. data/spec/models/scimitar/schema/attribute_spec.rb +3 -22
  47. data/spec/models/scimitar/schema/base_spec.rb +1 -1
  48. data/spec/models/scimitar/schema/user_spec.rb +2 -12
  49. data/spec/requests/active_record_backed_resources_controller_spec.rb +66 -1016
  50. data/spec/requests/application_controller_spec.rb +3 -16
  51. data/spec/requests/engine_spec.rb +0 -75
  52. data/spec/spec_helper.rb +1 -9
  53. data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +0 -108
  54. metadata +26 -37
  55. data/LICENSE.txt +0 -21
  56. data/README.md +0 -717
  57. data/lib/scimitar/support/utilities.rb +0 -111
  58. data/spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb +0 -25
  59. data/spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb +0 -25
  60. data/spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb +0 -24
  61. data/spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb +0 -25
@@ -139,31 +139,27 @@ module Scimitar
139
139
  # # ...
140
140
  # groups: [
141
141
  # {
142
- # list: :users, # <-- i.e. Team.users,
142
+ # list: :users, # <-- i.e. Team.users,
143
143
  # using: {
144
144
  # value: :id, # <-- i.e. Team.users[n].id
145
145
  # display: :full_name # <-- i.e. Team.users[n].full_name
146
146
  # },
147
- # class: Team, # Optional; see below
148
147
  # find_with: -> (scim_list_entry) {...} # See below
149
148
  # }
150
149
  # ],
151
150
  # #...
152
151
  # end
153
152
  #
154
- # The mixing-in class _must_ implement the read accessor identified by the
153
+ # The mixing-in class _must+ implement the read accessor identified by the
155
154
  # value of the "list" key, returning any indexed, Enumerable collection
156
155
  # (e.g. an Array or ActiveRecord::Relation instance). The optional key
157
- # ":find_with" is defined with a Proc that is passed the SCIM entry at each
156
+ # ":find_with" is defined with a Proc that's passed the SCIM entry at each
158
157
  # list position. It must use this to look up the equivalent entry for
159
158
  # association via the write accessor described by the ":list" key. In the
160
159
  # example above, "find_with"'s Proc might look at a SCIM entry value which
161
160
  # is expected to be a user ID and find that User. The mapped set of User
162
161
  # data thus found would be written back with "#users=", due to the ":list"
163
- # key declaring the method name ":users". The optional "class" key is
164
- # recommended but not really *needed* unless the configuration option
165
- # Scimitar::EngineConfiguration::schema_list_from_attribute_mappings is
166
- # defined; see documentation of that option for more information.
162
+ # key declaring the method name ":users".
167
163
  #
168
164
  # Note that you can only use either:
169
165
  #
@@ -180,8 +176,7 @@ module Scimitar
180
176
  # == scim_mutable_attributes
181
177
  #
182
178
  # Define this method to return a Set (preferred) or Array of names of
183
- # attributes which may be written in the mixing-in class. The names MUST be
184
- # expressed as Symbols, *not* Strings.
179
+ # attributes which may be written in the mixing-in class.
185
180
  #
186
181
  # If you return +nil+, it is assumed that +any+ attribute mapped by
187
182
  # ::scim_attributes_map which has a write accessor will be eligible for
@@ -209,12 +204,12 @@ module Scimitar
209
204
  # Define this method to return a Hash that maps field names you wish to
210
205
  # support in SCIM filter queries to corresponding attributes in the in the
211
206
  # mixing-in class. If +nil+ then filtering is not supported in the
212
- # ResourceController subclass which declares that it maps to the mixing-in
207
+ # ResouceController subclass which declares that it maps to the mixing-in
213
208
  # class. If not +nil+ but a SCIM filter enquiry is made for an unmapped
214
209
  # attribute, an 'invalid filter' exception is raised.
215
210
  #
216
211
  # If using ActiveRecord support in Scimitar::Lists::QueryParser, the mapped
217
- # entities are columns and that's expressed in the names of keys described
212
+ # entites are columns and that's expressed in the names of keys described
218
213
  # below; if you have other approaches to searching, these might be virtual
219
214
  # attributes or other such constructs rather than columns. That would be up
220
215
  # to your non-ActiveRecord's implementation to decide.
@@ -225,8 +220,13 @@ module Scimitar
225
220
  # allow for different client searching "styles", given ambiguities in RFC
226
221
  # 7644 filter examples).
227
222
  #
228
- # Each value is a hash of queryable SCIM attribute options, described
229
- # below - for example:
223
+ # Each value is a Hash with Symbol keys ':column', naming just one simple
224
+ # column for a mapping; ':columns', with an Array of column names that you
225
+ # want to map using 'OR' for a single search on the corresponding SCIM
226
+ # attribute; or ':ignore' with value 'true', which means that a fitler on
227
+ # the matching attribute is ignored rather than resulting in an "invalid
228
+ # filter" exception - beware possibilities for surprised clients getting a
229
+ # broader result set than expected. Example:
230
230
  #
231
231
  # def self.scim_queryable_attributes
232
232
  # return {
@@ -234,27 +234,10 @@ module Scimitar
234
234
  # 'name.familyName' => { column: :last_name },
235
235
  # 'emails' => { columns: [ :work_email_address, :home_email_address ] },
236
236
  # 'emails.value' => { columns: [ :work_email_address, :home_email_address ] },
237
- # 'emails.type' => { ignore: true },
238
- # 'groups.value' => { column: Group.arel_table[:id] }
237
+ # 'emails.type' => { ignore: true }
239
238
  # }
240
239
  # end
241
240
  #
242
- # Column references can be either a Symbol representing a column within
243
- # the resource model table, or an <tt>Arel::Attribute</tt> instance via
244
- # e.g. <tt>MyModel.arel_table[:my_column]</tt>.
245
- #
246
- # === Queryable SCIM attribute options
247
- #
248
- # +:column+:: Just one simple column for a mapping.
249
- #
250
- # +:columns+:: An Array of columns that you want to map using 'OR' for a
251
- # single search of the corresponding entity.
252
- #
253
- # +:ignore+:: When set to +true+, the matching attribute is ignored rather
254
- # than resulting in an "invalid filter" exception. Beware
255
- # possibilities for surprised clients getting a broader result
256
- # set than expected, since a constraint may have been ignored.
257
- #
258
241
  # Filtering is currently limited and searching within e.g. arrays of data
259
242
  # is not supported; only simple top-level keys can be mapped.
260
243
  #
@@ -267,8 +250,8 @@ module Scimitar
267
250
  # both of the keys 'created' and 'lastModified', as Symbols. The values
268
251
  # should be methods that the including method supports which return a
269
252
  # creation or most-recently-updated time, respectively. The returned object
270
- # must support #iso8601 to convert to a String representation. Example for
271
- # a typical ActiveRecord object with standard timestamps:
253
+ # mustsupport #iso8601 to convert to a String representation. Example for a
254
+ # typical ActiveRecord object with standard timestamps:
272
255
  #
273
256
  # def self.scim_timestamps_map
274
257
  # {
@@ -296,7 +279,7 @@ module Scimitar
296
279
  # the result in an instance variable.
297
280
  #
298
281
  def scim_mutable_attributes
299
- @scim_mutable_attributes ||= self.class.scim_mutable_attributes()&.map(&:to_sym)
282
+ @scim_mutable_attributes ||= self.class.scim_mutable_attributes()
300
283
 
301
284
  if @scim_mutable_attributes.nil?
302
285
  @scim_mutable_attributes = Set.new
@@ -340,36 +323,18 @@ module Scimitar
340
323
  @scim_queryable_attributes ||= self.class.scim_queryable_attributes()
341
324
  end
342
325
 
343
- # Render self as a SCIM object using ::scim_attributes_map. Fields that
344
- # are marked as <tt>returned: 'never'</tt> are excluded.
326
+ # Render self as a SCIM object using ::scim_attributes_map.
345
327
  #
346
- # +location+:: The location (HTTP(S) full URI) of this
347
- # resource in the domain of the object including
348
- # this mixin - "your" IDs, not the remote SCIM
349
- # client's external IDs. #url_for is a good way
350
- # to generate this.
328
+ # +location+:: The location (HTTP(S) full URI) of this resource, in the
329
+ # domain of the object including this mixin - "your" IDs,
330
+ # not the remote SCIM client's external IDs. #url_for is a
331
+ # good way to generate this.
351
332
  #
352
- # +include_attributes+:: The attributes that should be included in the
353
- # response, in the form of a list of full
354
- # attribute paths. Schema IDs are not supported.
355
- # See RFC 7644 section 3.9 and section 3.10 for
356
- # more. When a collection is given, +nil+ value
357
- # items are also excluded from the response. If
358
- # omitted or given an empty collection, all
359
- # attributes are included.
360
- #
361
- def to_scim(location:, include_attributes: [])
333
+ def to_scim(location:)
362
334
  map = self.class.scim_attributes_map()
363
- resource_type = self.class.scim_resource_type()
364
335
  timestamps_map = self.class.scim_timestamps_map() if self.class.respond_to?(:scim_timestamps_map)
365
- attrs_hash = self.to_scim_backend(
366
- data_source: self,
367
- resource_type: resource_type,
368
- attrs_map_or_leaf_value: map,
369
- include_attributes: include_attributes
370
- )
371
-
372
- resource = resource_type.new(attrs_hash)
336
+ attrs_hash = self.to_scim_backend(data_source: self, attrs_map_or_leaf_value: map)
337
+ resource = self.class.scim_resource_type().new(attrs_hash)
373
338
  meta_attrs_hash = { location: location }
374
339
 
375
340
  meta_attrs_hash[:created ] = self.send(timestamps_map[:created ])&.iso8601(0) if timestamps_map&.key?(:created)
@@ -396,39 +361,16 @@ module Scimitar
396
361
  #
397
362
  # Call ONLY for POST or PUT. For PATCH, see #from_scim_patch!.
398
363
  #
399
- # Mandatory named parameters:
400
- #
401
- # +scim_hash+:: A Hash that's the result of parsing a JSON payload
402
- # from an inbound POST or PUT request.
403
- #
404
- # Optional named parameters:
405
- #
406
- # +with_clearing+:: According to RFC 7644 section 3.5.1, PUT operations
407
- # MAY default or clear any attribute missing from
408
- # +scim_hash+ as this is deemed "not asserted by the
409
- # client" (see
410
- # https://tools.ietf.org/html/rfc7644#section-3.5.1).
411
- # This parameter controls such behaviour. It defaults
412
- # to +true+, so clearing is applied - single value
413
- # attributes are set to +nil+ and arrays are emptied.
414
- # If +false+, an unusual <b>preservation</b> mode is
415
- # applied and anything absent from +scim_hash+ will
416
- # have no impact on the target object (any mapped
417
- # attributes in the local data model with existing
418
- # non-nil values will retain those values).
364
+ # +scim_hash+:: A Hash that's the result of parsing a JSON payload
365
+ # from an inbound POST or PUT request.
419
366
  #
420
367
  # Returns 'self', for convenience of e.g. chaining other methods.
421
368
  #
422
- def from_scim!(scim_hash:, with_clearing: true)
369
+ def from_scim!(scim_hash:)
423
370
  scim_hash.freeze()
424
371
  map = self.class.scim_attributes_map().freeze()
425
372
 
426
- self.from_scim_backend!(
427
- attrs_map_or_leaf_value: map,
428
- scim_hash_or_leaf_value: scim_hash,
429
- with_clearing: with_clearing
430
- )
431
-
373
+ self.from_scim_backend!(attrs_map_or_leaf_value: map, scim_hash_or_leaf_value: scim_hash)
432
374
  return self
433
375
  end
434
376
 
@@ -464,11 +406,8 @@ module Scimitar
464
406
  def from_scim_patch!(patch_hash:)
465
407
  frozen_ci_patch_hash = patch_hash.with_indifferent_case_insensitive_access().freeze()
466
408
  ci_scim_hash = self.to_scim(location: '(unused)').as_json().with_indifferent_case_insensitive_access()
467
- operations = frozen_ci_patch_hash['operations']
468
409
 
469
- raise Scimitar::InvalidSyntaxError.new("Missing PATCH \"operations\"") unless operations
470
-
471
- operations.each do |operation|
410
+ frozen_ci_patch_hash['operations'].each do |operation|
472
411
  nature = operation['op' ]&.downcase
473
412
  path_str = operation['path' ]
474
413
  value = operation['value']
@@ -504,21 +443,11 @@ module Scimitar
504
443
  ci_scim_hash = { 'root' => ci_scim_hash }.with_indifferent_case_insensitive_access()
505
444
  end
506
445
 
507
- # Split the path into an array of path components, in a way
508
- # which is aware of extension schemas. See documentation of
509
- # Scimitar::Support::Utilities.path_str_to_array for details.
510
- #
511
- paths = ::Scimitar::Support::Utilities.path_str_to_array(
512
- self.class.scim_resource_type.extended_schemas,
513
- path_str
514
- )
515
-
516
446
  self.from_patch_backend!(
517
447
  nature: nature,
518
- path: paths,
448
+ path: (path_str || '').split('.'),
519
449
  value: value,
520
- altering_hash: ci_scim_hash,
521
- with_attr_map: self.class.scim_attributes_map()
450
+ altering_hash: ci_scim_hash
522
451
  )
523
452
 
524
453
  if extract_root
@@ -526,7 +455,7 @@ module Scimitar
526
455
  end
527
456
  end
528
457
 
529
- self.from_scim!(scim_hash: ci_scim_hash, with_clearing: false)
458
+ self.from_scim!(scim_hash: ci_scim_hash)
530
459
  return self
531
460
  end
532
461
 
@@ -544,71 +473,16 @@ module Scimitar
544
473
  # this is "self" (an instance of the
545
474
  # class mixing in this module).
546
475
  #
547
- # +resource_type+:: The resource type carrying the schemas
548
- # describing the SCIM object. If at the
549
- # top level when +data_source+ is +self+,
550
- # this would be sent as
551
- # <tt>self.class.scim_resource_type()</tt>.
552
- #
553
476
  # +attrs_map_or_leaf_value+:: The attribute map. At the top level,
554
477
  # this is from ::scim_attributes_map.
555
478
  #
556
- # +include_attributes+:: The attributes that should be included
557
- # in the response, in the form of a list
558
- # of full attribute paths. Schema IDs are
559
- # not supported. See RFC 7644 section
560
- # 3.9 and section 3.10 for more. When a
561
- # collection is given, +nil+ value items
562
- # are also excluded from the response. If
563
- # omitted or given an empty collection,
564
- # all attributes are included.
565
- #
566
- # Internal recursive calls also send:
567
- #
568
- # +attribute_path+:: Array of path components to the
569
- # attribute, which can be found through
570
- # +resource_type+ so that things like the
571
- # "+returned+" state can be checked.
572
- #
573
- def to_scim_backend(
574
- data_source:,
575
- resource_type:,
576
- attrs_map_or_leaf_value:,
577
- include_attributes:,
578
- attribute_path: []
579
- )
580
- # NOTE EARLY EXIT
581
- #
582
- return unless scim_attribute_included?(
583
- include_attributes: include_attributes,
584
- attribute_path: attribute_path
585
- )
586
-
587
- # On assumption of a top-level attributes list, the 'return never'
588
- # state is only checked on the recursive call from a Hash type. The
589
- # other handled types are assumed to only happen when called
590
- # recursively, so no need to check as no such call is made for a
591
- # 'return never' attribute.
592
- #
479
+ def to_scim_backend(data_source:, attrs_map_or_leaf_value:)
593
480
  case attrs_map_or_leaf_value
594
481
  when Hash # Expected at top-level of any map, or nested within
595
- result = attrs_map_or_leaf_value.each.with_object({}) do |(key, value), hash|
596
- nested_attribute_path = attribute_path + [key]
597
-
598
- if resource_type.find_attribute(*nested_attribute_path)&.returned != "never"
599
- hash[key] = to_scim_backend(
600
- data_source: data_source,
601
- resource_type: resource_type,
602
- attribute_path: nested_attribute_path,
603
- attrs_map_or_leaf_value: value,
604
- include_attributes: include_attributes
605
- )
606
- end
482
+ attrs_map_or_leaf_value.each.with_object({}) do |(key, value), hash|
483
+ hash[key] = to_scim_backend(data_source: data_source, attrs_map_or_leaf_value: value)
607
484
  end
608
485
 
609
- result.compact! if include_attributes.any?
610
- result
611
-
612
486
  when Array # Static or dynamic mapping against lists in data source
613
487
  built_dynamic_list = false
614
488
  mapped_array = attrs_map_or_leaf_value.map do |value|
@@ -617,28 +491,14 @@ module Scimitar
617
491
 
618
492
  elsif value.key?(:match) # Static map
619
493
  static_hash = { value[:match] => value[:with] }
620
- static_hash.merge!(
621
- to_scim_backend(
622
- data_source: data_source,
623
- resource_type: resource_type,
624
- attribute_path: attribute_path,
625
- attrs_map_or_leaf_value: value[:using],
626
- include_attributes: include_attributes
627
- )
628
- )
494
+ static_hash.merge!(to_scim_backend(data_source: data_source, attrs_map_or_leaf_value: value[:using]))
629
495
  static_hash
630
496
 
631
497
  elsif value.key?(:list) # Dynamic mapping of each complex list item
632
498
  built_dynamic_list = true
633
499
  list = data_source.public_send(value[:list])
634
500
  list.map do |list_entry|
635
- to_scim_backend(
636
- data_source: list_entry,
637
- resource_type: resource_type,
638
- attribute_path: attribute_path,
639
- attrs_map_or_leaf_value: value[:using],
640
- include_attributes: include_attributes
641
- )
501
+ to_scim_backend(data_source: list_entry, attrs_map_or_leaf_value: value[:using])
642
502
  end
643
503
 
644
504
  else # Unknown type, just treat as flat values
@@ -729,15 +589,6 @@ module Scimitar
729
589
  # read as input source material (left
730
590
  # hand side of the ASCII art diagram).
731
591
  #
732
- # +with_clearing+:: If +true+, attributes absent in
733
- # +scim_hash_or_leaf_value+ but present
734
- # in +attrs_map_or_leaf_value+ will be
735
- # cleared (+nil+ or empty array), for PUT
736
- # ("replace") semantics. If +false+, such
737
- # missing attribute values are left
738
- # untouched - whatever mapped value is in
739
- # +self+ is preserved.
740
- #
741
592
  # +path+:: Array of SCIM attribute names giving a
742
593
  # path into the SCIM schema where
743
594
  # iteration has reached. Used to find the
@@ -747,7 +598,6 @@ module Scimitar
747
598
  def from_scim_backend!(
748
599
  attrs_map_or_leaf_value:,
749
600
  scim_hash_or_leaf_value:,
750
- with_clearing:,
751
601
  path: []
752
602
  )
753
603
  scim_hash_or_leaf_value = scim_hash_or_leaf_value.with_indifferent_case_insensitive_access() if scim_hash_or_leaf_value.is_a?(Hash)
@@ -766,49 +616,13 @@ module Scimitar
766
616
  attrs_map_or_leaf_value.each do | scim_attribute, sub_attrs_map_or_leaf_value |
767
617
  next if scim_attribute&.to_s&.downcase == 'id' && path.empty?
768
618
 
769
- # Handle extension schema. Contributed by @bettysteger and
770
- # @MorrisFreeman via:
771
- #
772
- # https://github.com/RIPAGlobal/scimitar/issues/48
773
- # https://github.com/RIPAGlobal/scimitar/pull/49
774
- #
775
- # Note the shortcoming that attribute names within extensions
776
- # must be unique, as this mechanism basically just pulls out
777
- # extension attributes to the top level, losing what amounts
778
- # to the namespace that the extension schema ID provides.
779
- #
780
- attribute_tree = []
781
- resource_class.extended_schemas.each do |schema|
782
- if schema.scim_attributes.any? { |attribute| attribute.name == scim_attribute.to_s }
783
- attribute_tree << schema.id
784
- break # NOTE EARLY LOOP EXIT
785
- end
786
- end
787
- attribute_tree << scim_attribute.to_s
788
-
789
- continue_processing = if with_clearing
790
- true
791
- else
792
- most_of_attribute_tree = attribute_tree[...-1]
793
- last_attribute_in_tree = attribute_tree.last
619
+ sub_scim_hash_or_leaf_value = scim_hash_or_leaf_value&.dig(scim_attribute.to_s)
794
620
 
795
- if most_of_attribute_tree.empty?
796
- scim_hash_or_leaf_value&.key?(last_attribute_in_tree)
797
- else
798
- scim_hash_or_leaf_value&.dig(*most_of_attribute_tree)&.key?(last_attribute_in_tree)
799
- end
800
- end
801
-
802
- if continue_processing
803
- sub_scim_hash_or_leaf_value = scim_hash_or_leaf_value&.dig(*attribute_tree)
804
-
805
- self.from_scim_backend!(
806
- attrs_map_or_leaf_value: sub_attrs_map_or_leaf_value,
807
- scim_hash_or_leaf_value: sub_scim_hash_or_leaf_value, # May be 'nil'
808
- with_clearing: with_clearing,
809
- path: path + [scim_attribute]
810
- )
811
- end
621
+ self.from_scim_backend!(
622
+ attrs_map_or_leaf_value: sub_attrs_map_or_leaf_value,
623
+ scim_hash_or_leaf_value: sub_scim_hash_or_leaf_value, # May be 'nil'
624
+ path: path + [scim_attribute]
625
+ )
812
626
  end
813
627
 
814
628
  when Array # Static or dynamic maps
@@ -830,7 +644,6 @@ module Scimitar
830
644
  self.from_scim_backend!(
831
645
  attrs_map_or_leaf_value: sub_attrs_map,
832
646
  scim_hash_or_leaf_value: found_source_list_entry, # May be 'nil'
833
- with_clearing: with_clearing,
834
647
  path: path
835
648
  )
836
649
 
@@ -886,9 +699,7 @@ module Scimitar
886
699
  # +path+:: Operation path, as a series of array entries (so
887
700
  # an inbound dot-separated path string would first
888
701
  # be split into an array by the caller). For
889
- # internal recursive calls, this will be a subset
890
- # of array entries from an index somewhere into the
891
- # top-level array, through to its end.
702
+ # internal recursive calls, this will
892
703
  #
893
704
  # +value+:: The value to apply at the attribute(s) identified
894
705
  # by +path+. Ignored for 'remove' operations.
@@ -904,7 +715,7 @@ module Scimitar
904
715
  # own wrapping Hash with a single key addressing the SCIM object of
905
716
  # interest and supply this key as the sole array entry in +path+.
906
717
  #
907
- def from_patch_backend!(nature:, path:, value:, altering_hash:, with_attr_map:)
718
+ def from_patch_backend!(nature:, path:, value:, altering_hash:)
908
719
  raise 'Case sensitivity violation' unless altering_hash.is_a?(Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess)
909
720
 
910
721
  # These all throw exceptions if data is not as expected / required,
@@ -915,16 +726,14 @@ module Scimitar
915
726
  nature: nature,
916
727
  path: path,
917
728
  value: value,
918
- altering_hash: altering_hash,
919
- with_attr_map: with_attr_map
729
+ altering_hash: altering_hash
920
730
  )
921
731
  else
922
732
  from_patch_backend_traverse!(
923
733
  nature: nature,
924
734
  path: path,
925
735
  value: value,
926
- altering_hash: altering_hash,
927
- with_attr_map: with_attr_map
736
+ altering_hash: altering_hash
928
737
  )
929
738
  end
930
739
 
@@ -944,7 +753,7 @@ module Scimitar
944
753
  #
945
754
  # Happily throws exceptions if data is not as expected / required.
946
755
  #
947
- def from_patch_backend_traverse!(nature:, path:, value:, altering_hash:, with_attr_map:)
756
+ def from_patch_backend_traverse!(nature:, path:, value:, altering_hash:)
948
757
  raise 'Case sensitivity violation' unless altering_hash.is_a?(Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess)
949
758
 
950
759
  path_component, filter = extract_filter_from(path_component: path.first)
@@ -990,27 +799,11 @@ module Scimitar
990
799
  end
991
800
 
992
801
  found_data_for_recursion.each do | found_data |
993
- attr_map = if path_component.to_sym == :root
994
- with_attr_map
995
- else
996
- with_attr_map[path_component.to_sym]
997
- end
998
-
999
- # Static array mappings need us to find the right map entry that
1000
- # corresponds to the SCIM data at hand and recurse back into the
1001
- # patch engine with the ":using" attribute map data.
1002
- #
1003
- if attr_map.is_a?(Array)
1004
- array_attr_map = find_matching_static_attr_map(data: found_data, with_attr_map: attr_map)
1005
- attr_map = array_attr_map unless array_attr_map.nil?
1006
- end
1007
-
1008
802
  self.from_patch_backend!(
1009
803
  nature: nature,
1010
- path: path[1..],
804
+ path: path[1..-1],
1011
805
  value: value,
1012
- altering_hash: found_data,
1013
- with_attr_map: attr_map
806
+ altering_hash: found_data
1014
807
  )
1015
808
  end
1016
809
  end
@@ -1025,7 +818,7 @@ module Scimitar
1025
818
  #
1026
819
  # Happily throws exceptions if data is not as expected / required.
1027
820
  #
1028
- def from_patch_backend_apply!(nature:, path:, value:, altering_hash:, with_attr_map:)
821
+ def from_patch_backend_apply!(nature:, path:, value:, altering_hash:)
1029
822
  raise 'Case sensitivity violation' unless altering_hash.is_a?(Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess)
1030
823
 
1031
824
  path_component, filter = extract_filter_from(path_component: path.first)
@@ -1055,48 +848,11 @@ module Scimitar
1055
848
 
1056
849
  case nature
1057
850
  when 'remove'
1058
- handled = false
1059
- attr_map_path = path[..-2] + [path_component]
1060
- attr_map_entry = with_attr_map.dig(*attr_map_path.map(&:to_sym))
1061
-
1062
- # Deal with arrays specially; static maps require specific
1063
- # treatment, but dynamic or actual array values do not.
1064
- #
1065
- if attr_map_entry.is_a?(Array)
1066
- array_attr_map = find_matching_static_attr_map(
1067
- data: matched_hash,
1068
- with_attr_map: attr_map_entry
1069
- )
1070
-
1071
- # Found? Run through the mapped attributes. Anything that
1072
- # has an associated model attribute (i.e. some property
1073
- # that must be to be written into local data in response
1074
- # to the SCIM attribute being changed) is 'removed' by
1075
- # setting the corresponding value in "altering_hash" (of
1076
- # which "matched_hash" referenced fragment) to "nil".
1077
- #
1078
- handled = clear_data_for_removal!(
1079
- altering_hash: matched_hash,
1080
- with_attr_map: array_attr_map
1081
- )
1082
- end
1083
-
1084
- # For dynamic arrays or other value types, we assume that
1085
- # just clearing the item from the array or setting its SCIM
1086
- # attribute to "nil" will result in an appropriate update
1087
- # to the local data model (e.g. by a change in an Rails
1088
- # associated collection or clearing a local model attribute
1089
- # directly to "nil").
1090
- #
1091
- if handled == false
1092
- current_data_at_path[matched_index] = nil
1093
- compact_after = true
1094
- end
1095
-
851
+ current_data_at_path[matched_index] = nil
852
+ compact_after = true
1096
853
  when 'replace'
1097
854
  matched_hash.reject! { true }
1098
855
  matched_hash.merge!(value)
1099
-
1100
856
  end
1101
857
  end
1102
858
 
@@ -1135,169 +891,20 @@ module Scimitar
1135
891
  # at key 'members' with the above, rather than adding.
1136
892
  #
1137
893
  value.keys.each do | key |
1138
-
1139
- # Handle the Azure (Entra) case where keys might use
1140
- # dotted paths - see:
1141
- #
1142
- # https://github.com/RIPAGlobal/scimitar/issues/123
1143
- #
1144
- # ...along with keys containing schema IDs - see:
1145
- #
1146
- # https://is.docs.wso2.com/en/next/apis/scim2-patch-operations/#add-user-attributes
1147
- #
1148
- # ...and scroll down to example 3 of "Complex singular
1149
- # attributes".
1150
- #
1151
- subpaths = ::Scimitar::Support::Utilities.path_str_to_array(
1152
- self.class.scim_resource_type.extended_schemas,
1153
- key
1154
- )
1155
-
1156
894
  from_patch_backend!(
1157
895
  nature: nature,
1158
- path: path + subpaths,
896
+ path: path + [key],
1159
897
  value: value[key],
1160
- altering_hash: altering_hash,
1161
- with_attr_map: with_attr_map
898
+ altering_hash: altering_hash
1162
899
  )
1163
900
  end
1164
901
  else
1165
902
  altering_hash[path_component] = value
1166
903
  end
1167
-
1168
904
  when 'replace'
1169
- if path_component == 'root'
1170
- dot_pathed_value = value.inject({}) do |hash, (k, v)|
1171
- subpaths = ::Scimitar::Support::Utilities.path_str_to_array(
1172
- self.class.scim_resource_type.extended_schemas,
1173
- k
1174
- )
1175
-
1176
- hash.deep_merge!(::Scimitar::Support::Utilities.dot_path(subpaths, v))
1177
- end
1178
-
1179
- altering_hash[path_component].deep_merge!(dot_pathed_value)
1180
- else
1181
- altering_hash[path_component] = value
1182
- end
1183
-
1184
- # The array check handles payloads seen from e.g. Microsoft for
1185
- # remove-user-from-group, where contrary to examples in the RFC
1186
- # which would imply "payload removes all users", there is the
1187
- # clear intent to remove just one.
1188
- #
1189
- # https://tools.ietf.org/html/rfc7644#section-3.5.2.2
1190
- # https://learn.microsoft.com/en-us/azure/active-directory/app-provisioning/use-scim-to-provision-users-and-groups#update-group-remove-members
1191
- #
1192
- # Since remove-all in the face of remove-one is destructive, we
1193
- # do a special check here to see if there's an array value for
1194
- # the array path that the payload yielded. If so, we can match
1195
- # each value against array items and remove just those items.
1196
- #
1197
- # There is an additional special case to handle a bad example
1198
- # from Salesforce:
1199
- #
1200
- # https://help.salesforce.com/s/articleView?id=sf.identity_scim_manage_groups.htm&type=5
1201
- #
905
+ altering_hash[path_component] = value
1202
906
  when 'remove'
1203
- if altering_hash[path_component].is_a?(Array) && value.present?
1204
-
1205
- # Handle bad Salesforce example. That might be simply a
1206
- # documentation error, but just in case...
1207
- #
1208
- value = value.values.first if (
1209
- path_component&.downcase == 'members' &&
1210
- value.is_a?(Hash) &&
1211
- value.keys.size == 1 &&
1212
- value.keys.first&.downcase == 'members'
1213
- )
1214
-
1215
- # The Microsoft example provides an array of values, but we
1216
- # may as well cope with a value specified 'flat'. Promote
1217
- # such a thing to an Array to simplify the following code.
1218
- #
1219
- value = [value] unless value.is_a?(Array)
1220
-
1221
- # For each value item, delete matching array entries. The
1222
- # concept of "matching" is:
1223
- #
1224
- # * For simple non-Hash values (if possible) just delete on
1225
- # an exact match
1226
- #
1227
- # * For Hash-based values, only delete if all 'patch' keys
1228
- # are present in the resource and all values thus match.
1229
- #
1230
- # Special case to ignore '$ref' from the Microsoft payload.
1231
- #
1232
- # Note coercion to strings to account for SCIM vs the usual
1233
- # tricky case of underlying implementations with (say)
1234
- # integer primary keys, which all end up as strings anyway.
1235
- #
1236
- value.each do | value_item |
1237
- altering_hash[path_component].map! do | item |
1238
- item_is_matched = if item.is_a?(Hash) && value_item.is_a?(Hash)
1239
- matched_all = true
1240
- value_item.each do | value_key, value_value |
1241
- next if value_key == '$ref'
1242
- if ! item.key?(value_key) || item[value_key]&.to_s != value_value&.to_s
1243
- matched_all = false
1244
- end
1245
- end
1246
- matched_all
1247
- else
1248
- item&.to_s == value_item&.to_s
1249
- end
1250
-
1251
- if item_is_matched
1252
- handled = false
1253
- attr_map_path = path[..-2] + [path_component]
1254
- attr_map_entry = with_attr_map.dig(*attr_map_path.map(&:to_sym))
1255
- array_attr_map = find_matching_static_attr_map(
1256
- data: item,
1257
- with_attr_map: attr_map_entry
1258
- )
1259
-
1260
- handled = clear_data_for_removal!(
1261
- altering_hash: item,
1262
- with_attr_map: array_attr_map
1263
- )
1264
-
1265
- handled ? item : nil
1266
- else
1267
- item
1268
- end
1269
- end
1270
-
1271
- altering_hash[path_component].compact!
1272
- end
1273
-
1274
- elsif altering_hash[path_component].is_a?(Array)
1275
- handled = false
1276
- attr_map_path = path[..-2] + [path_component]
1277
- attr_map_entry = with_attr_map.dig(*attr_map_path.map(&:to_sym))
1278
-
1279
- if attr_map_entry.is_a?(Array) # Array mapping
1280
- altering_hash[path_component].each do | data_to_check |
1281
- array_attr_map = find_matching_static_attr_map(
1282
- data: data_to_check,
1283
- with_attr_map: attr_map_entry
1284
- )
1285
-
1286
- handled = clear_data_for_removal!(
1287
- altering_hash: data_to_check,
1288
- with_attr_map: array_attr_map
1289
- )
1290
- end
1291
- end
1292
-
1293
- if handled == false
1294
- altering_hash[path_component] = []
1295
- end
1296
-
1297
- else
1298
- altering_hash[path_component] = nil
1299
- end
1300
-
907
+ altering_hash.delete(path_component)
1301
908
  end
1302
909
  end
1303
910
  end
@@ -1374,200 +981,6 @@ module Scimitar
1374
981
  end
1375
982
  end
1376
983
 
1377
- # Static attribute maps are used where SCIM attributes include some
1378
- # kind of array, but it's not an arbitrary collection (dynamic maps
1379
- # handle those). Instead, specific matched values inside the SCIM
1380
- # data are mapped to specific attributes in the local data model.
1381
- #
1382
- # A typical example is for e-mails, where the SCIM "type" field in an
1383
- # array of e-mail addresses might get mapped to detect specific types
1384
- # of address such as "work" and "home", which happen to be stored
1385
- # locally in dedicated attributes (e.g. "work_email_address").
1386
- #
1387
- # During certain processing operations we end up with a set of data
1388
- # sent in from some SCIM operation and need to make modifications
1389
- # (e.g. for a PATCH) that require the attribute map corresponding to
1390
- # each part of the inbound SCIM data to be known. That's where this
1391
- # method comes in. Usually, it's not hard to traverse a path of SCIM
1392
- # data and dig a corresponding path through the attribute map Hash,
1393
- # except for static arrays. There, we need to know which of the
1394
- # static map entries matches a piece of SCIM data *from entries* in
1395
- # the array of SCIM data corresponding to the static map.
1396
- #
1397
- # Call here with a piece of SCIM data from an array, along with an
1398
- # attribute map fragment that must be the Array containing mappings.
1399
- # Static mapping entries from this are compared with the data and if
1400
- # a match is found, the sub-attribute map from the static entry's
1401
- # <tt>:using</tt> key is returned; else +nil+.
1402
- #
1403
- # Named parameters are:
1404
- #
1405
- # +data+:: A SCIM data entry from a SCIM data array which is
1406
- # mapped via the data given in the +with_attr_map+
1407
- # parameter.
1408
- #
1409
- # +with_attr_map+:: The attributes map fragment which must be an
1410
- # Array of mappings for the corresponding array
1411
- # in the SCIM data from which +data+ was drawn.
1412
- #
1413
- # For example, if SCIM data consisted of:
1414
- #
1415
- # {
1416
- # 'emails' => [
1417
- # {
1418
- # 'type' => 'work',
1419
- # 'value' => 'work_1@test.com'
1420
- # },
1421
- # {
1422
- # 'type' => 'work',
1423
- # 'value' => 'work_2@test.com'
1424
- # }
1425
- # ]
1426
- # }
1427
- #
1428
- # ...which was mapped to the local data model using the following
1429
- # attribute map:
1430
- #
1431
- # {
1432
- # emails: [
1433
- # { match: 'type', with: 'home', using: { value: :home_email } },
1434
- # { match: 'type', with: 'work', using: { value: :work_email } },
1435
- # ]
1436
- # }
1437
- #
1438
- # ...then when it came to processing the SCIM 'emails' entry, one of
1439
- # the array _entries_ therein would be passed in +data+, while the
1440
- # attribute map's <tt>:emails</tt> key's value (the _array_ of map
1441
- # data) would be given in <tt>:with_attr_map</tt>. The first SCIM
1442
- # array entry matches +work+ so the <tt>:using</tt> part of the map
1443
- # for that match would be returned:
1444
- #
1445
- # { value: :work_email }
1446
- #
1447
- # If there was a SCIM entry with a type of something unrecognised,
1448
- # such as 'holday', then +nil+ would be returned since there is no
1449
- # matching attribute map entry.
1450
- #
1451
- # Note that the <tt>:with_attr_map</tt> array can contain dynamic
1452
- # mappings or even be just a simple fixed array - only things that
1453
- # "look like" static mapping entries are processed (i.e. Hashes with
1454
- # a Symbol key of <tt>:match</tt> present), with the rest ignored.
1455
- #
1456
- def find_matching_static_attr_map(data:, with_attr_map:)
1457
- matched_map = with_attr_map.find do | static_or_dynamic_mapping |
1458
-
1459
- # Only interested in Static Array mappings.
1460
- #
1461
- if static_or_dynamic_mapping.is_a?(Hash) && static_or_dynamic_mapping.key?(:match)
1462
-
1463
- attr_to_match = static_or_dynamic_mapping[:match].to_s
1464
- value_to_match = static_or_dynamic_mapping[:with]
1465
- sub_attrs_map = static_or_dynamic_mapping[:using]
1466
-
1467
- # If this mapping refers to the matched data at hand,
1468
- # then we can process it further (see later below.
1469
- #
1470
- found = data[attr_to_match] == value_to_match
1471
-
1472
- # Not found? No static map match perhaps; this could be
1473
- # because a filter worked on a value which is fixed in
1474
- # the static map. For example, a filter might check for
1475
- # emails with "primary true", and the emergence of the
1476
- # value for "primary" might not be in the data model -
1477
- # it could be a constant declared in the 'using' part
1478
- # of a static map. Ugh! Check for that.
1479
- #
1480
- unless found
1481
- sub_attrs_map.each do | scim_attr, model_attr_or_constant |
1482
-
1483
- # Only want constants such as 'true' or 'false'.
1484
- #
1485
- next if model_attr_or_constant.is_a?(Symbol)
1486
-
1487
- # Does the static value match in the source data?
1488
- # E.g. a SCIM attribute :primary with value 'true'.
1489
- #
1490
- if data[scim_attr] == model_attr_or_constant
1491
- found = true
1492
- break
1493
- end
1494
- end
1495
- end
1496
-
1497
- found
1498
- else
1499
- false
1500
- end
1501
- end
1502
-
1503
- return matched_map&.dig(:using)
1504
- end
1505
-
1506
- # Related to #find_matching_static_attr_map - often, the reason to
1507
- # find a static array entry related to some inbound SCIM data is for
1508
- # a removal operation, where the way to "remove" the data in the
1509
- # local data model is to set an attribute to "nil". This means you
1510
- # need to know if there is an attribute writer related to the SCIM
1511
- # data being removed - and #find_matching_static_attr_map helps.
1512
- #
1513
- # With that done, you can call here with the hash data to be changed
1514
- # and fragment of attribute map that #find_matching_static_attr_map
1515
- # (or something like it) found.
1516
- #
1517
- # +altering_hash+:: The fragment of SCIM data that might be updated
1518
- # with +nil+ to ultimately lead to an atttribute
1519
- # writer identified through +with_attr_map+ being
1520
- # called with that value. This is often the same
1521
- # that was passed in the +data+ attribute in a
1522
- # prior #find_matching_static_attr_map call.
1523
- #
1524
- # +with_attr_map:: The map fragment that corresponds exactly to the
1525
- # +altering_hash+ data - e.g. the return value of a
1526
- # prior #find_matching_static_attr_map call.
1527
- #
1528
- # Update +altering_hash+ in place if the map finds a relevant local
1529
- # data model attribute and returns +true+. If no changes are made,
1530
- # returns +false+.
1531
- #
1532
- def clear_data_for_removal!(altering_hash:, with_attr_map:)
1533
- handled = false
1534
-
1535
- with_attr_map&.each do | scim_attr, model_attr_or_constant |
1536
-
1537
- # Only process attribute names, not constants.
1538
- #
1539
- next unless model_attr_or_constant.is_a?(Symbol)
1540
-
1541
- altering_hash[scim_attr] = nil
1542
- handled = true
1543
- end
1544
-
1545
- return handled
1546
- end
1547
-
1548
- # Related to to_scim_backend, this methods tells whether +attribute_path+
1549
- # should be included in the current +include_attributes+. This method
1550
- # implements the attributes request from RFC 7644, section 3.9 and 3.10.
1551
- #
1552
- # +include_attributes+:: The attributes that should be included
1553
- # in the response, in the form of a list of
1554
- # full attribute paths. See RFC 7644 section
1555
- # 3.9 and section 3.10. An empty collection
1556
- # will include all attributes.
1557
- #
1558
- # +attribute_path+:: Array of path components to the attribute,
1559
- # e.g. <tt>["name", "givenName"]</tt>.
1560
- #
1561
- def scim_attribute_included?(include_attributes:, attribute_path:)
1562
- return true unless attribute_path.any? && include_attributes.any?
1563
-
1564
- full_path = attribute_path.join(".")
1565
- attribute_included = full_path.start_with?(*include_attributes)
1566
- will_include_nested = include_attributes.any? { |att| att.start_with?(full_path) }
1567
-
1568
- attribute_included || will_include_nested
1569
- end
1570
-
1571
984
  end # "included do"
1572
985
  end # "module Mixin"
1573
986
  end # "module Resources"