scimitar 1.8.1 → 1.10.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -20
  3. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +5 -4
  4. data/app/controllers/scimitar/resource_types_controller.rb +0 -2
  5. data/app/controllers/scimitar/resources_controller.rb +0 -2
  6. data/app/controllers/scimitar/schemas_controller.rb +361 -3
  7. data/app/controllers/scimitar/service_provider_configurations_controller.rb +0 -1
  8. data/app/models/scimitar/engine_configuration.rb +3 -1
  9. data/app/models/scimitar/lists/query_parser.rb +88 -3
  10. data/app/models/scimitar/resources/base.rb +48 -14
  11. data/app/models/scimitar/resources/mixin.rb +531 -71
  12. data/app/models/scimitar/schema/name.rb +2 -2
  13. data/app/models/scimitar/schema/user.rb +10 -10
  14. data/config/initializers/scimitar.rb +41 -0
  15. data/lib/scimitar/engine.rb +57 -12
  16. data/lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb +140 -10
  17. data/lib/scimitar/support/utilities.rb +60 -0
  18. data/lib/scimitar/version.rb +2 -2
  19. data/spec/apps/dummy/app/models/mock_user.rb +18 -3
  20. data/spec/apps/dummy/config/initializers/scimitar.rb +31 -2
  21. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +1 -0
  22. data/spec/apps/dummy/db/schema.rb +1 -0
  23. data/spec/controllers/scimitar/schemas_controller_spec.rb +342 -54
  24. data/spec/models/scimitar/lists/query_parser_spec.rb +70 -0
  25. data/spec/models/scimitar/resources/base_spec.rb +20 -12
  26. data/spec/models/scimitar/resources/base_validation_spec.rb +16 -3
  27. data/spec/models/scimitar/resources/mixin_spec.rb +754 -122
  28. data/spec/models/scimitar/schema/user_spec.rb +2 -2
  29. data/spec/requests/active_record_backed_resources_controller_spec.rb +312 -5
  30. data/spec/requests/engine_spec.rb +75 -0
  31. data/spec/spec_helper.rb +1 -1
  32. data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +108 -0
  33. metadata +22 -22
@@ -139,27 +139,31 @@ 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
147
148
  # find_with: -> (scim_list_entry) {...} # See below
148
149
  # }
149
150
  # ],
150
151
  # #...
151
152
  # end
152
153
  #
153
- # The mixing-in class _must+ implement the read accessor identified by the
154
+ # The mixing-in class _must_ implement the read accessor identified by the
154
155
  # value of the "list" key, returning any indexed, Enumerable collection
155
156
  # (e.g. an Array or ActiveRecord::Relation instance). The optional key
156
- # ":find_with" is defined with a Proc that's passed the SCIM entry at each
157
+ # ":find_with" is defined with a Proc that is passed the SCIM entry at each
157
158
  # list position. It must use this to look up the equivalent entry for
158
159
  # association via the write accessor described by the ":list" key. In the
159
160
  # example above, "find_with"'s Proc might look at a SCIM entry value which
160
161
  # is expected to be a user ID and find that User. The mapped set of User
161
162
  # data thus found would be written back with "#users=", due to the ":list"
162
- # key declaring the method name ":users".
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.
163
167
  #
164
168
  # Note that you can only use either:
165
169
  #
@@ -176,7 +180,8 @@ module Scimitar
176
180
  # == scim_mutable_attributes
177
181
  #
178
182
  # Define this method to return a Set (preferred) or Array of names of
179
- # attributes which may be written in the mixing-in class.
183
+ # attributes which may be written in the mixing-in class. The names MUST be
184
+ # expressed as Symbols, *not* Strings.
180
185
  #
181
186
  # If you return +nil+, it is assumed that +any+ attribute mapped by
182
187
  # ::scim_attributes_map which has a write accessor will be eligible for
@@ -204,12 +209,12 @@ module Scimitar
204
209
  # Define this method to return a Hash that maps field names you wish to
205
210
  # support in SCIM filter queries to corresponding attributes in the in the
206
211
  # mixing-in class. If +nil+ then filtering is not supported in the
207
- # ResouceController subclass which declares that it maps to the mixing-in
212
+ # ResourceController subclass which declares that it maps to the mixing-in
208
213
  # class. If not +nil+ but a SCIM filter enquiry is made for an unmapped
209
214
  # attribute, an 'invalid filter' exception is raised.
210
215
  #
211
216
  # If using ActiveRecord support in Scimitar::Lists::QueryParser, the mapped
212
- # entites are columns and that's expressed in the names of keys described
217
+ # entities are columns and that's expressed in the names of keys described
213
218
  # below; if you have other approaches to searching, these might be virtual
214
219
  # attributes or other such constructs rather than columns. That would be up
215
220
  # to your non-ActiveRecord's implementation to decide.
@@ -262,8 +267,8 @@ module Scimitar
262
267
  # both of the keys 'created' and 'lastModified', as Symbols. The values
263
268
  # should be methods that the including method supports which return a
264
269
  # creation or most-recently-updated time, respectively. The returned object
265
- # mustsupport #iso8601 to convert to a String representation. Example for a
266
- # typical ActiveRecord object with standard timestamps:
270
+ # must support #iso8601 to convert to a String representation. Example for
271
+ # a typical ActiveRecord object with standard timestamps:
267
272
  #
268
273
  # def self.scim_timestamps_map
269
274
  # {
@@ -291,7 +296,7 @@ module Scimitar
291
296
  # the result in an instance variable.
292
297
  #
293
298
  def scim_mutable_attributes
294
- @scim_mutable_attributes ||= self.class.scim_mutable_attributes()
299
+ @scim_mutable_attributes ||= self.class.scim_mutable_attributes()&.map(&:to_sym)
295
300
 
296
301
  if @scim_mutable_attributes.nil?
297
302
  @scim_mutable_attributes = Set.new
@@ -335,18 +340,36 @@ module Scimitar
335
340
  @scim_queryable_attributes ||= self.class.scim_queryable_attributes()
336
341
  end
337
342
 
338
- # Render self as a SCIM object using ::scim_attributes_map.
343
+ # Render self as a SCIM object using ::scim_attributes_map. Fields that
344
+ # are marked as <tt>returned: 'never'</tt> are excluded.
339
345
  #
340
- # +location+:: The location (HTTP(S) full URI) of this resource, in the
341
- # domain of the object including this mixin - "your" IDs,
342
- # not the remote SCIM client's external IDs. #url_for is a
343
- # good way to generate this.
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.
344
351
  #
345
- def to_scim(location:)
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: [])
346
362
  map = self.class.scim_attributes_map()
363
+ resource_type = self.class.scim_resource_type()
347
364
  timestamps_map = self.class.scim_timestamps_map() if self.class.respond_to?(:scim_timestamps_map)
348
- attrs_hash = self.to_scim_backend(data_source: self, attrs_map_or_leaf_value: map)
349
- resource = self.class.scim_resource_type().new(attrs_hash)
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)
350
373
  meta_attrs_hash = { location: location }
351
374
 
352
375
  meta_attrs_hash[:created ] = self.send(timestamps_map[:created ])&.iso8601(0) if timestamps_map&.key?(:created)
@@ -373,16 +396,39 @@ module Scimitar
373
396
  #
374
397
  # Call ONLY for POST or PUT. For PATCH, see #from_scim_patch!.
375
398
  #
376
- # +scim_hash+:: A Hash that's the result of parsing a JSON payload
377
- # from an inbound POST or PUT request.
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).
378
419
  #
379
420
  # Returns 'self', for convenience of e.g. chaining other methods.
380
421
  #
381
- def from_scim!(scim_hash:)
422
+ def from_scim!(scim_hash:, with_clearing: true)
382
423
  scim_hash.freeze()
383
424
  map = self.class.scim_attributes_map().freeze()
384
425
 
385
- self.from_scim_backend!(attrs_map_or_leaf_value: map, scim_hash_or_leaf_value: scim_hash)
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
+
386
432
  return self
387
433
  end
388
434
 
@@ -458,32 +504,21 @@ module Scimitar
458
504
  ci_scim_hash = { 'root' => ci_scim_hash }.with_indifferent_case_insensitive_access()
459
505
  end
460
506
 
461
- # Handle extension schema. Contributed by @bettysteger and
462
- # @MorrisFreeman via:
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.
463
510
  #
464
- # https://github.com/RIPAGlobal/scimitar/issues/48
465
- # https://github.com/RIPAGlobal/scimitar/pull/49
466
- #
467
- # Note the ":" separating the schema ID (URN) from the attribute.
468
- # The nature of JSON rendering / other payloads might lead you to
469
- # expect a "." as with any complex types, but that's not the case;
470
- # see https://tools.ietf.org/html/rfc7644#section-3.10, or
471
- # https://tools.ietf.org/html/rfc7644#section-3.5.2 of which in
472
- # particular, https://tools.ietf.org/html/rfc7644#page-35.
473
- #
474
- paths = []
475
- self.class.scim_resource_type.extended_schemas.each do |schema|
476
- path_str.downcase.split(schema.id.downcase + ':').drop(1).each do |path|
477
- paths += [schema.id] + path.split('.')
478
- end
479
- end
480
- paths = path_str.split('.') if paths.empty?
511
+ paths = ::Scimitar::Support::Utilities.path_str_to_array(
512
+ self.class.scim_resource_type.extended_schemas,
513
+ path_str
514
+ )
481
515
 
482
516
  self.from_patch_backend!(
483
517
  nature: nature,
484
518
  path: paths,
485
519
  value: value,
486
- altering_hash: ci_scim_hash
520
+ altering_hash: ci_scim_hash,
521
+ with_attr_map: self.class.scim_attributes_map()
487
522
  )
488
523
 
489
524
  if extract_root
@@ -491,7 +526,7 @@ module Scimitar
491
526
  end
492
527
  end
493
528
 
494
- self.from_scim!(scim_hash: ci_scim_hash)
529
+ self.from_scim!(scim_hash: ci_scim_hash, with_clearing: false)
495
530
  return self
496
531
  end
497
532
 
@@ -509,16 +544,71 @@ module Scimitar
509
544
  # this is "self" (an instance of the
510
545
  # class mixing in this module).
511
546
  #
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
+ #
512
553
  # +attrs_map_or_leaf_value+:: The attribute map. At the top level,
513
554
  # this is from ::scim_attributes_map.
514
555
  #
515
- def to_scim_backend(data_source:, attrs_map_or_leaf_value:)
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
+ #
516
593
  case attrs_map_or_leaf_value
517
594
  when Hash # Expected at top-level of any map, or nested within
518
- attrs_map_or_leaf_value.each.with_object({}) do |(key, value), hash|
519
- hash[key] = to_scim_backend(data_source: data_source, attrs_map_or_leaf_value: value)
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
520
607
  end
521
608
 
609
+ result.compact! if include_attributes.any?
610
+ result
611
+
522
612
  when Array # Static or dynamic mapping against lists in data source
523
613
  built_dynamic_list = false
524
614
  mapped_array = attrs_map_or_leaf_value.map do |value|
@@ -527,14 +617,28 @@ module Scimitar
527
617
 
528
618
  elsif value.key?(:match) # Static map
529
619
  static_hash = { value[:match] => value[:with] }
530
- static_hash.merge!(to_scim_backend(data_source: data_source, attrs_map_or_leaf_value: value[:using]))
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
+ )
531
629
  static_hash
532
630
 
533
631
  elsif value.key?(:list) # Dynamic mapping of each complex list item
534
632
  built_dynamic_list = true
535
633
  list = data_source.public_send(value[:list])
536
634
  list.map do |list_entry|
537
- to_scim_backend(data_source: list_entry, attrs_map_or_leaf_value: value[:using])
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
+ )
538
642
  end
539
643
 
540
644
  else # Unknown type, just treat as flat values
@@ -625,6 +729,15 @@ module Scimitar
625
729
  # read as input source material (left
626
730
  # hand side of the ASCII art diagram).
627
731
  #
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
+ #
628
741
  # +path+:: Array of SCIM attribute names giving a
629
742
  # path into the SCIM schema where
630
743
  # iteration has reached. Used to find the
@@ -634,6 +747,7 @@ module Scimitar
634
747
  def from_scim_backend!(
635
748
  attrs_map_or_leaf_value:,
636
749
  scim_hash_or_leaf_value:,
750
+ with_clearing:,
637
751
  path: []
638
752
  )
639
753
  scim_hash_or_leaf_value = scim_hash_or_leaf_value.with_indifferent_case_insensitive_access() if scim_hash_or_leaf_value.is_a?(Hash)
@@ -658,19 +772,43 @@ module Scimitar
658
772
  # https://github.com/RIPAGlobal/scimitar/issues/48
659
773
  # https://github.com/RIPAGlobal/scimitar/pull/49
660
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
+ #
661
780
  attribute_tree = []
662
781
  resource_class.extended_schemas.each do |schema|
663
- attribute_tree << schema.id and break if schema.scim_attributes.any? { |attribute| attribute.name == scim_attribute.to_s }
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
664
786
  end
665
787
  attribute_tree << scim_attribute.to_s
666
788
 
667
- sub_scim_hash_or_leaf_value = scim_hash_or_leaf_value&.dig(*attribute_tree)
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
794
+
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
668
801
 
669
- self.from_scim_backend!(
670
- attrs_map_or_leaf_value: sub_attrs_map_or_leaf_value,
671
- scim_hash_or_leaf_value: sub_scim_hash_or_leaf_value, # May be 'nil'
672
- path: path + [scim_attribute]
673
- )
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
674
812
  end
675
813
 
676
814
  when Array # Static or dynamic maps
@@ -692,6 +830,7 @@ module Scimitar
692
830
  self.from_scim_backend!(
693
831
  attrs_map_or_leaf_value: sub_attrs_map,
694
832
  scim_hash_or_leaf_value: found_source_list_entry, # May be 'nil'
833
+ with_clearing: with_clearing,
695
834
  path: path
696
835
  )
697
836
 
@@ -747,7 +886,9 @@ module Scimitar
747
886
  # +path+:: Operation path, as a series of array entries (so
748
887
  # an inbound dot-separated path string would first
749
888
  # be split into an array by the caller). For
750
- # internal recursive calls, this will
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.
751
892
  #
752
893
  # +value+:: The value to apply at the attribute(s) identified
753
894
  # by +path+. Ignored for 'remove' operations.
@@ -763,7 +904,7 @@ module Scimitar
763
904
  # own wrapping Hash with a single key addressing the SCIM object of
764
905
  # interest and supply this key as the sole array entry in +path+.
765
906
  #
766
- def from_patch_backend!(nature:, path:, value:, altering_hash:)
907
+ def from_patch_backend!(nature:, path:, value:, altering_hash:, with_attr_map:)
767
908
  raise 'Case sensitivity violation' unless altering_hash.is_a?(Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess)
768
909
 
769
910
  # These all throw exceptions if data is not as expected / required,
@@ -774,14 +915,16 @@ module Scimitar
774
915
  nature: nature,
775
916
  path: path,
776
917
  value: value,
777
- altering_hash: altering_hash
918
+ altering_hash: altering_hash,
919
+ with_attr_map: with_attr_map
778
920
  )
779
921
  else
780
922
  from_patch_backend_traverse!(
781
923
  nature: nature,
782
924
  path: path,
783
925
  value: value,
784
- altering_hash: altering_hash
926
+ altering_hash: altering_hash,
927
+ with_attr_map: with_attr_map
785
928
  )
786
929
  end
787
930
 
@@ -801,7 +944,7 @@ module Scimitar
801
944
  #
802
945
  # Happily throws exceptions if data is not as expected / required.
803
946
  #
804
- def from_patch_backend_traverse!(nature:, path:, value:, altering_hash:)
947
+ def from_patch_backend_traverse!(nature:, path:, value:, altering_hash:, with_attr_map:)
805
948
  raise 'Case sensitivity violation' unless altering_hash.is_a?(Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess)
806
949
 
807
950
  path_component, filter = extract_filter_from(path_component: path.first)
@@ -847,11 +990,27 @@ module Scimitar
847
990
  end
848
991
 
849
992
  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
+
850
1008
  self.from_patch_backend!(
851
1009
  nature: nature,
852
- path: path[1..-1],
1010
+ path: path[1..],
853
1011
  value: value,
854
- altering_hash: found_data
1012
+ altering_hash: found_data,
1013
+ with_attr_map: attr_map
855
1014
  )
856
1015
  end
857
1016
  end
@@ -866,7 +1025,7 @@ module Scimitar
866
1025
  #
867
1026
  # Happily throws exceptions if data is not as expected / required.
868
1027
  #
869
- def from_patch_backend_apply!(nature:, path:, value:, altering_hash:)
1028
+ def from_patch_backend_apply!(nature:, path:, value:, altering_hash:, with_attr_map:)
870
1029
  raise 'Case sensitivity violation' unless altering_hash.is_a?(Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess)
871
1030
 
872
1031
  path_component, filter = extract_filter_from(path_component: path.first)
@@ -896,11 +1055,48 @@ module Scimitar
896
1055
 
897
1056
  case nature
898
1057
  when 'remove'
899
- current_data_at_path[matched_index] = nil
900
- compact_after = true
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
+
901
1096
  when 'replace'
902
1097
  matched_hash.reject! { true }
903
1098
  matched_hash.merge!(value)
1099
+
904
1100
  end
905
1101
  end
906
1102
 
@@ -939,11 +1135,30 @@ module Scimitar
939
1135
  # at key 'members' with the above, rather than adding.
940
1136
  #
941
1137
  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
+
942
1156
  from_patch_backend!(
943
1157
  nature: nature,
944
- path: path + [key],
1158
+ path: path + subpaths,
945
1159
  value: value[key],
946
- altering_hash: altering_hash
1160
+ altering_hash: altering_hash,
1161
+ with_attr_map: with_attr_map
947
1162
  )
948
1163
  end
949
1164
  else
@@ -953,8 +1168,14 @@ module Scimitar
953
1168
  when 'replace'
954
1169
  if path_component == 'root'
955
1170
  dot_pathed_value = value.inject({}) do |hash, (k, v)|
956
- hash.deep_merge!(::Scimitar::Support::Utilities.dot_path(k.split('.'), 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))
957
1177
  end
1178
+
958
1179
  altering_hash[path_component].deep_merge!(dot_pathed_value)
959
1180
  else
960
1181
  altering_hash[path_component] = value
@@ -1013,8 +1234,8 @@ module Scimitar
1013
1234
  # integer primary keys, which all end up as strings anyway.
1014
1235
  #
1015
1236
  value.each do | value_item |
1016
- altering_hash[path_component].delete_if do | item |
1017
- if item.is_a?(Hash) && value_item.is_a?(Hash)
1237
+ altering_hash[path_component].map! do | item |
1238
+ item_is_matched = if item.is_a?(Hash) && value_item.is_a?(Hash)
1018
1239
  matched_all = true
1019
1240
  value_item.each do | value_key, value_value |
1020
1241
  next if value_key == '$ref'
@@ -1026,10 +1247,55 @@ module Scimitar
1026
1247
  else
1027
1248
  item&.to_s == value_item&.to_s
1028
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
+ )
1029
1290
  end
1030
1291
  end
1292
+
1293
+ if handled == false
1294
+ altering_hash[path_component] = []
1295
+ end
1296
+
1031
1297
  else
1032
- altering_hash.delete(path_component)
1298
+ altering_hash[path_component] = nil
1033
1299
  end
1034
1300
 
1035
1301
  end
@@ -1108,6 +1374,200 @@ module Scimitar
1108
1374
  end
1109
1375
  end
1110
1376
 
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
+
1111
1571
  end # "included do"
1112
1572
  end # "module Mixin"
1113
1573
  end # "module Resources"