scimitar 1.8.1 → 1.10.0

Sign up to get free protection for your applications and to get access to all the features.
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"