scimitar 2.5.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -0
  3. data/README.md +721 -0
  4. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +72 -18
  5. data/app/controllers/scimitar/application_controller.rb +17 -9
  6. data/app/controllers/scimitar/resource_types_controller.rb +7 -3
  7. data/app/controllers/scimitar/resources_controller.rb +0 -2
  8. data/app/controllers/scimitar/schemas_controller.rb +366 -3
  9. data/app/controllers/scimitar/service_provider_configurations_controller.rb +3 -2
  10. data/app/models/scimitar/complex_types/address.rb +0 -6
  11. data/app/models/scimitar/complex_types/base.rb +2 -2
  12. data/app/models/scimitar/engine_configuration.rb +3 -1
  13. data/app/models/scimitar/lists/query_parser.rb +97 -12
  14. data/app/models/scimitar/resource_invalid_error.rb +1 -1
  15. data/app/models/scimitar/resource_type.rb +4 -6
  16. data/app/models/scimitar/resources/base.rb +52 -8
  17. data/app/models/scimitar/resources/mixin.rb +539 -76
  18. data/app/models/scimitar/schema/attribute.rb +18 -8
  19. data/app/models/scimitar/schema/base.rb +2 -2
  20. data/app/models/scimitar/schema/name.rb +2 -2
  21. data/app/models/scimitar/schema/user.rb +10 -10
  22. data/config/initializers/scimitar.rb +49 -3
  23. data/lib/scimitar/engine.rb +57 -12
  24. data/lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb +140 -10
  25. data/lib/scimitar/support/utilities.rb +111 -0
  26. data/lib/scimitar/version.rb +2 -2
  27. data/lib/scimitar.rb +1 -0
  28. data/spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb +25 -0
  29. data/spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb +25 -0
  30. data/spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb +24 -0
  31. data/spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb +25 -0
  32. data/spec/apps/dummy/app/models/mock_user.rb +20 -3
  33. data/spec/apps/dummy/config/application.rb +8 -0
  34. data/spec/apps/dummy/config/initializers/scimitar.rb +40 -3
  35. data/spec/apps/dummy/config/routes.rb +18 -1
  36. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +2 -0
  37. data/spec/apps/dummy/db/schema.rb +3 -1
  38. data/spec/controllers/scimitar/application_controller_spec.rb +56 -2
  39. data/spec/controllers/scimitar/resource_types_controller_spec.rb +8 -4
  40. data/spec/controllers/scimitar/schemas_controller_spec.rb +344 -48
  41. data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +1 -0
  42. data/spec/models/scimitar/complex_types/address_spec.rb +3 -4
  43. data/spec/models/scimitar/lists/query_parser_spec.rb +70 -0
  44. data/spec/models/scimitar/resources/base_spec.rb +55 -13
  45. data/spec/models/scimitar/resources/base_validation_spec.rb +16 -3
  46. data/spec/models/scimitar/resources/mixin_spec.rb +781 -124
  47. data/spec/models/scimitar/schema/attribute_spec.rb +22 -0
  48. data/spec/models/scimitar/schema/user_spec.rb +2 -2
  49. data/spec/requests/active_record_backed_resources_controller_spec.rb +723 -40
  50. data/spec/requests/engine_spec.rb +75 -0
  51. data/spec/spec_helper.rb +10 -2
  52. data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +108 -0
  53. metadata +42 -34
@@ -93,14 +93,23 @@ module Scimitar
93
93
  end
94
94
 
95
95
  def valid_simple_type?(value)
96
- valid = (type == 'string' && value.is_a?(String)) ||
97
- (type == 'boolean' && (value.is_a?(TrueClass) || value.is_a?(FalseClass))) ||
98
- (type == 'integer' && (value.is_a?(Integer))) ||
99
- (type == 'dateTime' && valid_date_time?(value))
100
- errors.add(self.name, "has the wrong type. It has to be a(n) #{self.type}.") unless valid
96
+ if multiValued
97
+ valid = value.is_a?(Array) && value.all? { |v| simple_type?(v) }
98
+ errors.add(self.name, "or one of its elements has the wrong type. It has to be an array of #{self.type}s.") unless valid
99
+ else
100
+ valid = simple_type?(value)
101
+ errors.add(self.name, "has the wrong type. It has to be a(n) #{self.type}.") unless valid
102
+ end
101
103
  valid
102
104
  end
103
105
 
106
+ def simple_type?(value)
107
+ (type == 'string' && value.is_a?(String)) ||
108
+ (type == 'boolean' && (value.is_a?(TrueClass) || value.is_a?(FalseClass))) ||
109
+ (type == 'integer' && (value.is_a?(Integer))) ||
110
+ (type == 'dateTime' && valid_date_time?(value))
111
+ end
112
+
104
113
  def valid_date_time?(value)
105
114
  !!Time.iso8601(value)
106
115
  rescue ArgumentError
@@ -113,9 +122,10 @@ module Scimitar
113
122
  end
114
123
 
115
124
  def as_json(options = {})
116
- options[:except] ||= ['complexType']
117
- options[:except] << 'canonicalValues' if canonicalValues.empty?
118
- super.except(options)
125
+ exclusions = options[:except] || ['complexType']
126
+ exclusions << 'canonicalValues' if canonicalValues.empty?
127
+
128
+ super(options.merge(except: exclusions))
119
129
  end
120
130
 
121
131
  end
@@ -13,7 +13,7 @@ module Scimitar
13
13
 
14
14
  # Converts the schema to its json representation that will be returned by /SCHEMAS end-point of a SCIM service provider.
15
15
  def as_json(options = {})
16
- @meta.location = Scimitar::Engine.routes.url_helpers.scim_schemas_path(name: id)
16
+ @meta.location ||= Scimitar::Engine.routes.url_helpers.scim_schemas_path(name: id)
17
17
  original = super
18
18
  original.merge('attributes' => original.delete('scim_attributes'))
19
19
  end
@@ -36,7 +36,7 @@ module Scimitar
36
36
  scim_attributes.map { |scim_attribute| scim_attribute.clone }
37
37
  end
38
38
 
39
- # Find a given attribute this schema, travelling down a path to any
39
+ # Find a given attribute of this schema, travelling down a path to any
40
40
  # sub-attributes within. Given that callers might be dealing with paths
41
41
  # into actual SCIM data, array indices for multi-value attributes are
42
42
  # allowed (as integers) and simply skipped - only the names are of
@@ -6,8 +6,8 @@ module Scimitar
6
6
 
7
7
  def self.scim_attributes
8
8
  @scim_attributes ||= [
9
- Attribute.new(name: 'familyName', type: 'string', required: true),
10
- Attribute.new(name: 'givenName', type: 'string', required: true),
9
+ Attribute.new(name: 'familyName', type: 'string'),
10
+ Attribute.new(name: 'givenName', type: 'string'),
11
11
  Attribute.new(name: 'middleName', type: 'string'),
12
12
  Attribute.new(name: 'formatted', type: 'string'),
13
13
  Attribute.new(name: 'honorificPrefix', type: 'string'),
@@ -20,7 +20,7 @@ module Scimitar
20
20
  [
21
21
  Attribute.new(name: 'userName', type: 'string', uniqueness: 'server', required: true),
22
22
 
23
- Attribute.new(name: 'name', complexType: Scimitar::ComplexTypes::Name),
23
+ Attribute.new(name: 'name', complexType: Scimitar::ComplexTypes::Name),
24
24
 
25
25
  Attribute.new(name: 'displayName', type: 'string'),
26
26
  Attribute.new(name: 'nickName', type: 'string'),
@@ -35,15 +35,15 @@ module Scimitar
35
35
 
36
36
  Attribute.new(name: 'password', type: 'string', mutability: 'writeOnly', returned: 'never'),
37
37
 
38
- Attribute.new(name: 'emails', multiValued: true, complexType: Scimitar::ComplexTypes::Email),
39
- Attribute.new(name: 'phoneNumbers', multiValued: true, complexType: Scimitar::ComplexTypes::PhoneNumber),
40
- Attribute.new(name: 'ims', multiValued: true, complexType: Scimitar::ComplexTypes::Ims),
41
- Attribute.new(name: 'photos', multiValued: true, complexType: Scimitar::ComplexTypes::Photo),
42
- Attribute.new(name: 'addresses', multiValued: true, complexType: Scimitar::ComplexTypes::Address),
43
- Attribute.new(name: 'groups', multiValued: true, complexType: Scimitar::ComplexTypes::ReferenceGroup, mutability: 'readOnly'),
44
- Attribute.new(name: 'entitlements', multiValued: true, complexType: Scimitar::ComplexTypes::Entitlement),
45
- Attribute.new(name: 'roles', multiValued: true, complexType: Scimitar::ComplexTypes::Role),
46
- Attribute.new(name: 'x509Certificates', multiValued: true, complexType: Scimitar::ComplexTypes::X509Certificate),
38
+ Attribute.new(name: 'emails', multiValued: true, complexType: Scimitar::ComplexTypes::Email),
39
+ Attribute.new(name: 'phoneNumbers', multiValued: true, complexType: Scimitar::ComplexTypes::PhoneNumber),
40
+ Attribute.new(name: 'ims', multiValued: true, complexType: Scimitar::ComplexTypes::Ims),
41
+ Attribute.new(name: 'photos', multiValued: true, complexType: Scimitar::ComplexTypes::Photo),
42
+ Attribute.new(name: 'addresses', multiValued: true, complexType: Scimitar::ComplexTypes::Address),
43
+ Attribute.new(name: 'groups', multiValued: true, complexType: Scimitar::ComplexTypes::ReferenceGroup, mutability: 'readOnly'),
44
+ Attribute.new(name: 'entitlements', multiValued: true, complexType: Scimitar::ComplexTypes::Entitlement),
45
+ Attribute.new(name: 'roles', multiValued: true, complexType: Scimitar::ComplexTypes::Role),
46
+ Attribute.new(name: 'x509Certificates', multiValued: true, complexType: Scimitar::ComplexTypes::X509Certificate),
47
47
  ]
48
48
  end
49
49
 
@@ -38,9 +38,10 @@ Rails.application.config.to_prepare do # (required for >= Rails 7 / Zeitwerk)
38
38
  Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
39
39
 
40
40
  # If you have filters you want to run for any Scimitar action/route, you
41
- # can define them here. For example, you might use a before-action to set
42
- # up some multi-tenancy related state, or skip Rails CSRF token
43
- # verification. For example:
41
+ # can define them here. You can also override any shared controller methods
42
+ # here. For example, you might use a before-action to set up some
43
+ # multi-tenancy related state, skip Rails CSRF token verification, or
44
+ # customise how Scimitar generates URLs:
44
45
  #
45
46
  # application_controller_mixin: Module.new do
46
47
  # def self.included(base)
@@ -54,6 +55,10 @@ Rails.application.config.to_prepare do # (required for >= Rails 7 / Zeitwerk)
54
55
  # prepend_before_action :setup_some_kind_of_multi_tenancy_data
55
56
  # end
56
57
  # end
58
+ #
59
+ # def scim_schemas_url(options)
60
+ # super(custom_param: 'value', **options)
61
+ # end
57
62
  # end, # ...other configuration entries might follow...
58
63
 
59
64
  # If you want to support username/password authentication:
@@ -101,6 +106,47 @@ Rails.application.config.to_prepare do # (required for >= Rails 7 / Zeitwerk)
101
106
  # whatever that means for you receiving system in your model code.
102
107
  #
103
108
  # optional_value_fields_required: false
109
+
110
+ # The SCIM standard `/Schemas` endpoint lists, by default, all known schema
111
+ # definitions with the mutabilty (read-write, read-only, write-only) state
112
+ # described by those definitions, and includes all defined attributes. For
113
+ # user-defined schema, this will typically exactly match your underlying
114
+ # mapped attribute and model capability - it wouldn't make sense to define
115
+ # your own schema that misrepresented the implementation! For core SCIM RFC
116
+ # schema, though, you might want to only list actually mapped attributes.
117
+ # Further, if you happen to have a non-compliant implementation especially
118
+ # in relation to mutability of some attributes, you may want to report that
119
+ # accurately in the '/Schemas' list, for auto-discovery purposes. To switch
120
+ # to a significantly slower but more accurate render method for the list,
121
+ # driven by your resource subclasses and their attribute maps, set:
122
+ #
123
+ # schema_list_from_attribute_mappings: [...array...]
124
+ #
125
+ # ...where you provide an Array of *models*, your classes that include the
126
+ # Scimitar::Resources::Mixin module and, therefore, define an attribute map
127
+ # translating SCIM schema attributes into actual implemented data. These
128
+ # must *uniquely* describe, via the Scimitar resources they each declare in
129
+ # their Scimitar::Resources::Mixin::scim_resource_type implementation, the
130
+ # set of schemas and extended schemas you want to render. Should resources
131
+ # share schema, the '/Schemas' endpoint will fail since it cannot determine
132
+ # which model attribute map it should use and it needs the map in order to
133
+ # resolve the differences (if any) between what the schema might say, and
134
+ # what the actual underlying model supports.
135
+ #
136
+ # It is further _very_ _strongly_ _recommended_ that, for any
137
+ # +scim_attributes_map+ containing a collection which has "list:" key (for
138
+ # an associative array of zero or more entities; the Groups to which a User
139
+ # might belong is a good example) then you should also specify the "class:"
140
+ # key, giving the class used for objects in that associated collection. The
141
+ # class *must* include Scimitar::Resources::Mixin, since its own attribute
142
+ # map is consulted in order to render the part of the schema describing
143
+ # those associated properties in the owning resource. If you don't do this,
144
+ # and if you're using ActiveRecord, then Scimitar attempts association
145
+ # reflection to determine the collection class - but that's more fragile
146
+ # than just being told the exact class in the attribute map. No matter how
147
+ # this class is determined, though, it must be possible to create a simple
148
+ # instance with +new+ and no parameters, since that's needed in order to
149
+ # call Scimitar::Resources::Mixin#scim_mutable_attributes.
104
150
  })
105
151
 
106
152
  end
@@ -1,15 +1,38 @@
1
+ require 'rails/engine'
2
+
1
3
  module Scimitar
2
4
  class Engine < ::Rails::Engine
3
5
  isolate_namespace Scimitar
4
6
 
7
+ config.autoload_once_paths = %W(
8
+ #{root}/app/controllers
9
+ #{root}/app/models
10
+ )
11
+
5
12
  Mime::Type.register 'application/scim+json', :scim
6
13
 
7
14
  ActionDispatch::Request.parameter_parsers[Mime::Type.lookup('application/scim+json').symbol] = lambda do |body|
8
15
  JSON.parse(body)
9
16
  end
10
17
 
18
+ # Return an Array of all supported default and custom resource classes.
19
+ # See also :add_custom_resource and :set_default_resources.
20
+ #
11
21
  def self.resources
12
- default_resources + custom_resources
22
+ self.default_resources() + self.custom_resources()
23
+ end
24
+
25
+ # Returns a flat array of instances of all resource schema included in the
26
+ # resource classes returned by ::resources.
27
+ #
28
+ def self.schemas
29
+ self.resources().map(&:schemas).flatten.uniq.map(&:new)
30
+ end
31
+
32
+ # Returns the list of custom resources, if any.
33
+ #
34
+ def self.custom_resources
35
+ @custom_resources ||= []
13
36
  end
14
37
 
15
38
  # Can be used to add a new resource type which is not provided by the gem.
@@ -30,7 +53,7 @@ module Scimitar
30
53
  # Scimitar::Engine.add_custom_resource Scim::Resources::ShinyResource
31
54
  #
32
55
  def self.add_custom_resource(resource)
33
- custom_resources << resource
56
+ self.custom_resources() << resource
34
57
  end
35
58
 
36
59
  # Resets the resource list to default. This is really only intended for use
@@ -40,23 +63,45 @@ module Scimitar
40
63
  @custom_resources = []
41
64
  end
42
65
 
43
- # Returns the list of custom resources, if any.
44
- #
45
- def self.custom_resources
46
- @custom_resources ||= []
47
- end
48
-
49
- # Returns the default resources added in this gem:
66
+ # Returns the default resources added in this gem - by default, these are:
50
67
  #
51
68
  # * Scimitar::Resources::User
52
69
  # * Scimitar::Resources::Group
53
70
  #
71
+ # ...but if an implementation does not e.g. support Group, it can
72
+ # be overridden via ::set_default_resources to help with service
73
+ # auto-discovery.
74
+ #
54
75
  def self.default_resources
55
- [ Resources::User, Resources::Group ]
76
+ @standard_default_resources = [ Resources::User, Resources::Group ]
77
+ @default_resources ||= @standard_default_resources.dup()
56
78
  end
57
79
 
58
- def self.schemas
59
- resources.map(&:schemas).flatten.uniq.map(&:new)
80
+ # Override the resources returned by ::default_resources.
81
+ #
82
+ # +resource_array+:: An Array containing one or both of
83
+ # Scimitar::Resources::User and/or
84
+ # Scimitar::Resources::Group, and nothing else.
85
+ #
86
+ def self.set_default_resources(resource_array)
87
+ self.default_resources()
88
+ unrecognised_resources = resource_array - @standard_default_resources
89
+
90
+ if unrecognised_resources.any?
91
+ raise "Scimitar::Engine.set_default_resources: Only #{@standard_default_resources.map(&:name).join(', ')} are supported"
92
+ elsif resource_array.empty?
93
+ raise 'Scimitar::Engine.set_default_resources: At least one resource must be given'
94
+ end
95
+
96
+ @default_resources = resource_array
97
+ end
98
+
99
+ # Resets the default resource list. This is really only intended for use
100
+ # during testing, to avoid one test polluting another.
101
+ #
102
+ def self.reset_default_resources
103
+ self.default_resources()
104
+ @default_resources = @standard_default_resources
60
105
  end
61
106
 
62
107
  end
@@ -23,8 +23,8 @@ class Hash
23
23
  #
24
24
  def self.deep_indifferent_case_insensitive_access(object)
25
25
  if object.is_a?(Hash)
26
- new_hash = Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess.new(object)
27
- new_hash.each do | key, value |
26
+ new_hash = Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess.new
27
+ object.each do | key, value |
28
28
  new_hash[key] = deep_indifferent_case_insensitive_access(value)
29
29
  end
30
30
  new_hash
@@ -49,34 +49,164 @@ module Scimitar
49
49
  # in a case-insensitive fashion too.
50
50
  #
51
51
  # During enumeration, Hash keys will always be returned in whatever case
52
- # they were originally set.
52
+ # they were originally set. Just as with
53
+ # ActiveSupport::HashWithIndifferentAccess, though, the type of the keys is
54
+ # always returned as a String, even if originally set as a Symbol - only
55
+ # the upper/lower case nature of the original key is preserved.
56
+ #
57
+ # If a key is written more than once with the same effective meaning in a
58
+ # to-string, to-downcase form, then whatever case was used *first* wins;
59
+ # e.g. if you did hash['User'] = 23, then hash['USER'] = 42, the result
60
+ # would be {"User" => 42}.
61
+ #
62
+ # It's important to remember that Hash#merge is shallow and replaces values
63
+ # found at existing keys in the target ("this") hash with values in the
64
+ # inbound Hash. If that new value that is itself a Hash, this *replaces*
65
+ # the value. For example:
66
+ #
67
+ # * Original: <tt>'Foo' => { 'Bar' => 42 }</tt>
68
+ # * Merge: <tt>'FOO' => { 'BAR' => 24 }</tt>
69
+ #
70
+ # ...results in "this" target hash's key +Foo+ being addressed in the merge
71
+ # by inbound key +FOO+, so the case doesn't change. But the value for +Foo+
72
+ # is _replaced_ by the merging-in Hash completely:
73
+ #
74
+ # * Result: <tt>'Foo' => { 'BAR' => 24 }</tt>
75
+ #
76
+ # ...and of course we might've replaced with a totally different type, such
77
+ # as +true+:
78
+ #
79
+ # * Original: <tt>'Foo' => { 'Bar' => 42 }</tt>
80
+ # * Merge: <tt>'FOO' => true</tt>
81
+ # * Result: <tt>'Foo' => true</tt>
82
+ #
83
+ # If you're intending to merge nested Hashes, then use ActiveSupport's
84
+ # #deep_merge or an equivalent. This will have the expected outcome, where
85
+ # the hash with 'BAR' is _merged_ into the existing value and, therefore,
86
+ # the original 'Bar' key case is preserved:
87
+ #
88
+ # * Original: <tt>'Foo' => { 'Bar' => 42 }</tt>
89
+ # * Deep merge: <tt>'FOO' => { 'BAR' => 24 }</tt>
90
+ # * Result: <tt>'Foo' => { 'Bar' => 24 }</tt>
53
91
  #
54
92
  class HashWithIndifferentCaseInsensitiveAccess < ActiveSupport::HashWithIndifferentAccess
55
93
  def with_indifferent_case_insensitive_access
56
94
  self
57
95
  end
58
96
 
97
+ def initialize(constructor = nil)
98
+ @scimitar_hash_with_indifferent_case_insensitive_access_key_map = {}
99
+ super
100
+ end
101
+
102
+ # It's vital that the attribute map is carried over when one of these
103
+ # objects is duplicated. Duplication of this ivar state does *not* happen
104
+ # when 'dup' is called on our superclass, so we have to do that manually.
105
+ #
106
+ def dup
107
+ duplicate = super
108
+ duplicate.instance_variable_set(
109
+ '@scimitar_hash_with_indifferent_case_insensitive_access_key_map',
110
+ @scimitar_hash_with_indifferent_case_insensitive_access_key_map
111
+ )
112
+
113
+ return duplicate
114
+ end
115
+
116
+ # Override the individual key writer.
117
+ #
118
+ def []=(key, value)
119
+ string_key = scimitar_hash_with_indifferent_case_insensitive_access_string(key)
120
+ indifferent_key = scimitar_hash_with_indifferent_case_insensitive_access_downcase(string_key)
121
+ converted_value = convert_value(value, conversion: :assignment)
122
+
123
+ # Note '||=', as there might have been a prior use of the "same" key in
124
+ # a different case. The earliest one is preserved since the actual Hash
125
+ # underneath all this is already using that variant of the key.
126
+ #
127
+ key_for_writing = (
128
+ @scimitar_hash_with_indifferent_case_insensitive_access_key_map[indifferent_key] ||= string_key
129
+ )
130
+
131
+ regular_writer(key_for_writing, converted_value)
132
+ end
133
+
134
+ # Override #merge to express it in terms of #merge! (also overridden), so
135
+ # that merged hashes can have their keys treated indifferently too.
136
+ #
137
+ def merge(*other_hashes, &block)
138
+ dup.merge!(*other_hashes, &block)
139
+ end
140
+
141
+ # Modifies-self version of #merge, overriding Hash#merge!.
142
+ #
143
+ def merge!(*hashes_to_merge_to_self, &block)
144
+ if block_given?
145
+ hashes_to_merge_to_self.each do |hash_to_merge_to_self|
146
+ hash_to_merge_to_self.each_pair do |key, value|
147
+ value = block.call(key, self[key], value) if self.key?(key)
148
+ self[key] = value
149
+ end
150
+ end
151
+ else
152
+ hashes_to_merge_to_self.each do |hash_to_merge_to_self|
153
+ hash_to_merge_to_self.each_pair do |key, value|
154
+ self[key] = value
155
+ end
156
+ end
157
+ end
158
+
159
+ self
160
+ end
161
+
162
+ # =======================================================================
163
+ # PRIVATE INSTANCE METHODS
164
+ # =======================================================================
165
+ #
59
166
  private
60
167
 
61
168
  if Symbol.method_defined?(:name)
62
- def convert_key(key)
63
- key.kind_of?(Symbol) ? key.name.downcase : key.downcase
169
+ def scimitar_hash_with_indifferent_case_insensitive_access_string(key)
170
+ key.kind_of?(Symbol) ? key.name : key
64
171
  end
65
172
  else
66
- def convert_key(key)
67
- key.kind_of?(Symbol) ? key.to_s.downcase : key.downcase
173
+ def scimitar_hash_with_indifferent_case_insensitive_access_string(key)
174
+ key.kind_of?(Symbol) ? key.to_s : key
175
+ end
176
+ end
177
+
178
+ def scimitar_hash_with_indifferent_case_insensitive_access_downcase(key)
179
+ key.kind_of?(String) ? key.downcase : key
180
+ end
181
+
182
+ def convert_key(key)
183
+ string_key = scimitar_hash_with_indifferent_case_insensitive_access_string(key)
184
+ indifferent_key = scimitar_hash_with_indifferent_case_insensitive_access_downcase(string_key)
185
+
186
+ @scimitar_hash_with_indifferent_case_insensitive_access_key_map[indifferent_key] || string_key
187
+ end
188
+
189
+ def convert_value(value, conversion: nil)
190
+ if value.is_a?(Hash)
191
+ if conversion == :to_hash
192
+ value.to_hash
193
+ else
194
+ value.with_indifferent_case_insensitive_access
195
+ end
196
+ else
197
+ super
68
198
  end
69
199
  end
70
200
 
71
201
  def update_with_single_argument(other_hash, block)
72
- if other_hash.is_a? HashWithIndifferentCaseInsensitiveAccess
202
+ if other_hash.is_a?(HashWithIndifferentCaseInsensitiveAccess)
73
203
  regular_update(other_hash, &block)
74
204
  else
75
205
  other_hash.to_hash.each_pair do |key, value|
76
206
  if block && key?(key)
77
- value = block.call(convert_key(key), self[key], value)
207
+ value = block.call(self.convert_key(key), self[key], value)
78
208
  end
79
- regular_writer(convert_key(key), convert_value(value))
209
+ self.[]=(key, value)
80
210
  end
81
211
  end
82
212
  end
@@ -0,0 +1,111 @@
1
+ module Scimitar
2
+
3
+ # Namespace containing various chunks of Scimitar support code that don't
4
+ # logically fit into other areas.
5
+ #
6
+ module Support
7
+
8
+ # A namespace that contains various stand-alone utility methods which act
9
+ # as helpers for other parts of the code base, without risking namespace
10
+ # pollution by e.g. being part of a module loaded into a client class.
11
+ #
12
+ module Utilities
13
+
14
+ # Takes an array of components that usually come from a dotted path such
15
+ # as <tt>foo.bar.baz</tt>, along with a value that is found at the end of
16
+ # that path, then converts it into a nested Hash with each level of the
17
+ # Hash corresponding to a step along the path.
18
+ #
19
+ # This was written to help with edge case SCIM uses where (most often, at
20
+ # least) inbound calls use a dotted notation where nested values are more
21
+ # commonly accepted; converting to nesting makes it easier for subsequent
22
+ # processing code, which needs only handle nested Hash data.
23
+ #
24
+ # As an example, passing:
25
+ #
26
+ # ['foo', 'bar', 'baz'], 'value'
27
+ #
28
+ # ...yields:
29
+ #
30
+ # {'foo' => {'bar' => {'baz' => 'value'}}}
31
+ #
32
+ # Parameters:
33
+ #
34
+ # +array+:: Array containing path components, usually acquired from a
35
+ # string with dot separators and a call to String#split.
36
+ #
37
+ # +value+:: The value found at the path indicated by +array+.
38
+ #
39
+ # If +array+ is empty, +value+ is returned directly, with no nesting
40
+ # Hash wrapping it.
41
+ #
42
+ def self.dot_path(array, value)
43
+ return value if array.empty?
44
+
45
+ {}.tap do | hash |
46
+ hash[array.shift()] = self.dot_path(array, value)
47
+ end
48
+ end
49
+
50
+ # Schema ID-aware splitter handling ":" or "." separators. Adapted from
51
+ # contribution by @bettysteger and @MorrisFreeman in:
52
+ #
53
+ # https://github.com/pond/scimitar/issues/48
54
+ # https://github.com/pond/scimitar/pull/49
55
+ #
56
+ # +schemas:: Array of extension schemas, e.g. a SCIM resource class'
57
+ # <tt>scim_resource_type.extended_schemas</tt> value. The
58
+ # Array should be empty if there are no extensions.
59
+ #
60
+ # +path_str+:: Path String, e.g. <tt>"password"</tt>, <tt>"name.givenName"</tt>,
61
+ # <tt>"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"</tt> (special case),
62
+ # <tt>"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization"</tt>
63
+ # (if given a Symbol, it'll be converted to a String).
64
+ #
65
+ # Returns an array of components, e.g. <tt>["password"]</tt>, <tt>["name",
66
+ # "givenName"]</tt>,
67
+ # <tt>["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"]</tt> (special case),
68
+ # <tt>["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", "organization"]</tt>.
69
+ #
70
+ # The called-out special case is for a schema ID without any appended
71
+ # path components, which is returned as a single element ID to aid in
72
+ # traversal particularly of things like PATCH requests. There, a "value"
73
+ # attribute might have a key string that's simply a schema ID, with an
74
+ # object beneath that's got attribute-name pairs, possibly nested, in a
75
+ # path-free payload.
76
+ #
77
+ def self.path_str_to_array(schemas, path_str)
78
+ path_str = path_str.to_s
79
+ components = []
80
+
81
+ # Note the ":" separating the schema ID (URN) from the attribute.
82
+ # The nature of JSON rendering / other payloads might lead you to
83
+ # expect a "." as with any complex types, but that's not the case;
84
+ # see https://tools.ietf.org/html/rfc7644#section-3.10, or
85
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2 of which in
86
+ # particular, https://tools.ietf.org/html/rfc7644#page-35.
87
+ #
88
+ if path_str.include?(':')
89
+ lower_case_path_str = path_str.downcase()
90
+
91
+ schemas.each do |schema|
92
+ lower_case_schema_id = schema.id.downcase()
93
+ attributes_after_schema_id = lower_case_path_str.split(lower_case_schema_id + ':').drop(1)
94
+
95
+ if attributes_after_schema_id.empty?
96
+ components += [schema.id] if lower_case_path_str == lower_case_schema_id
97
+ else
98
+ attributes_after_schema_id.each do |component|
99
+ components += [schema.id] + component.split('.')
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ components = path_str.split('.') if components.empty?
106
+ return components
107
+ end
108
+
109
+ end
110
+ end
111
+ end
@@ -3,11 +3,11 @@ module Scimitar
3
3
  # Gem version. If this changes, be sure to re-run "bundle install" or
4
4
  # "bundle update".
5
5
  #
6
- VERSION = '2.5.0'
6
+ VERSION = '2.11.0'
7
7
 
8
8
  # Date for VERSION. If this changes, be sure to re-run "bundle install"
9
9
  # or "bundle update".
10
10
  #
11
- DATE = '2023-09-25'
11
+ DATE = '2025-03-05'
12
12
 
13
13
  end
data/lib/scimitar.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require 'scimitar/version'
2
2
  require 'scimitar/support/hash_with_indifferent_case_insensitive_access'
3
+ require 'scimitar/support/utilities'
3
4
  require 'scimitar/engine'
4
5
 
5
6
  module Scimitar
@@ -0,0 +1,25 @@
1
+ # For tests only - uses custom 'create' implementation which passes a block to
2
+ # Scimitar::ActiveRecordBackedResourcesController#create.
3
+ #
4
+ class CustomCreateMockUsersController < Scimitar::ActiveRecordBackedResourcesController
5
+
6
+ OVERRIDDEN_NAME = SecureRandom.uuid
7
+
8
+ def create
9
+ super do | resource |
10
+ resource.first_name = OVERRIDDEN_NAME
11
+ resource.save!
12
+ end
13
+ end
14
+
15
+ protected
16
+
17
+ def storage_class
18
+ MockUser
19
+ end
20
+
21
+ def storage_scope
22
+ MockUser.all
23
+ end
24
+
25
+ end
@@ -0,0 +1,25 @@
1
+ # For tests only - uses custom 'replace' implementation which passes a block to
2
+ # Scimitar::ActiveRecordBackedResourcesController#create.
3
+ #
4
+ class CustomReplaceMockUsersController < Scimitar::ActiveRecordBackedResourcesController
5
+
6
+ OVERRIDDEN_NAME = SecureRandom.uuid
7
+
8
+ def replace
9
+ super do | resource |
10
+ resource.first_name = OVERRIDDEN_NAME
11
+ resource.save!
12
+ end
13
+ end
14
+
15
+ protected
16
+
17
+ def storage_class
18
+ MockUser
19
+ end
20
+
21
+ def storage_scope
22
+ MockUser.all
23
+ end
24
+
25
+ end