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.
- checksums.yaml +4 -4
- data/README.md +27 -20
- data/app/controllers/scimitar/active_record_backed_resources_controller.rb +5 -4
- data/app/controllers/scimitar/resource_types_controller.rb +0 -2
- data/app/controllers/scimitar/resources_controller.rb +0 -2
- data/app/controllers/scimitar/schemas_controller.rb +361 -3
- data/app/controllers/scimitar/service_provider_configurations_controller.rb +0 -1
- data/app/models/scimitar/engine_configuration.rb +3 -1
- data/app/models/scimitar/lists/query_parser.rb +88 -3
- data/app/models/scimitar/resources/base.rb +48 -14
- data/app/models/scimitar/resources/mixin.rb +531 -71
- data/app/models/scimitar/schema/name.rb +2 -2
- data/app/models/scimitar/schema/user.rb +10 -10
- data/config/initializers/scimitar.rb +41 -0
- data/lib/scimitar/engine.rb +57 -12
- data/lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb +140 -10
- data/lib/scimitar/support/utilities.rb +60 -0
- data/lib/scimitar/version.rb +2 -2
- data/spec/apps/dummy/app/models/mock_user.rb +18 -3
- data/spec/apps/dummy/config/initializers/scimitar.rb +31 -2
- data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +1 -0
- data/spec/apps/dummy/db/schema.rb +1 -0
- data/spec/controllers/scimitar/schemas_controller_spec.rb +342 -54
- data/spec/models/scimitar/lists/query_parser_spec.rb +70 -0
- data/spec/models/scimitar/resources/base_spec.rb +20 -12
- data/spec/models/scimitar/resources/base_validation_spec.rb +16 -3
- data/spec/models/scimitar/resources/mixin_spec.rb +754 -122
- data/spec/models/scimitar/schema/user_spec.rb +2 -2
- data/spec/requests/active_record_backed_resources_controller_spec.rb +312 -5
- data/spec/requests/engine_spec.rb +75 -0
- data/spec/spec_helper.rb +1 -1
- data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +108 -0
- metadata +22 -22
@@ -139,27 +139,31 @@ module Scimitar
|
|
139
139
|
# # ...
|
140
140
|
# groups: [
|
141
141
|
# {
|
142
|
-
# list:
|
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
|
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
|
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
|
-
#
|
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
|
-
#
|
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
|
-
#
|
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+::
|
341
|
-
#
|
342
|
-
#
|
343
|
-
#
|
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
|
-
|
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(
|
349
|
-
|
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
|
-
#
|
377
|
-
#
|
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!(
|
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
|
-
#
|
462
|
-
#
|
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
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
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
|
-
|
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
|
-
|
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!(
|
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(
|
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
|
-
|
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
|
-
|
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
|
-
|
670
|
-
|
671
|
-
|
672
|
-
|
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
|
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
|
-
|
900
|
-
|
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 +
|
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
|
-
|
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].
|
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
|
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"
|