scimitar 1.10.0 → 2.0.0

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