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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +721 -0
- data/app/controllers/scimitar/active_record_backed_resources_controller.rb +72 -18
- data/app/controllers/scimitar/application_controller.rb +17 -9
- data/app/controllers/scimitar/resource_types_controller.rb +7 -3
- data/app/controllers/scimitar/resources_controller.rb +0 -2
- data/app/controllers/scimitar/schemas_controller.rb +366 -3
- data/app/controllers/scimitar/service_provider_configurations_controller.rb +3 -2
- data/app/models/scimitar/complex_types/address.rb +0 -6
- data/app/models/scimitar/complex_types/base.rb +2 -2
- data/app/models/scimitar/engine_configuration.rb +3 -1
- data/app/models/scimitar/lists/query_parser.rb +97 -12
- data/app/models/scimitar/resource_invalid_error.rb +1 -1
- data/app/models/scimitar/resource_type.rb +4 -6
- data/app/models/scimitar/resources/base.rb +52 -8
- data/app/models/scimitar/resources/mixin.rb +539 -76
- data/app/models/scimitar/schema/attribute.rb +18 -8
- data/app/models/scimitar/schema/base.rb +2 -2
- data/app/models/scimitar/schema/name.rb +2 -2
- data/app/models/scimitar/schema/user.rb +10 -10
- data/config/initializers/scimitar.rb +49 -3
- 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 +111 -0
- data/lib/scimitar/version.rb +2 -2
- data/lib/scimitar.rb +1 -0
- data/spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb +25 -0
- data/spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb +25 -0
- data/spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb +24 -0
- data/spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb +25 -0
- data/spec/apps/dummy/app/models/mock_user.rb +20 -3
- data/spec/apps/dummy/config/application.rb +8 -0
- data/spec/apps/dummy/config/initializers/scimitar.rb +40 -3
- data/spec/apps/dummy/config/routes.rb +18 -1
- data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +2 -0
- data/spec/apps/dummy/db/schema.rb +3 -1
- data/spec/controllers/scimitar/application_controller_spec.rb +56 -2
- data/spec/controllers/scimitar/resource_types_controller_spec.rb +8 -4
- data/spec/controllers/scimitar/schemas_controller_spec.rb +344 -48
- data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +1 -0
- data/spec/models/scimitar/complex_types/address_spec.rb +3 -4
- data/spec/models/scimitar/lists/query_parser_spec.rb +70 -0
- data/spec/models/scimitar/resources/base_spec.rb +55 -13
- data/spec/models/scimitar/resources/base_validation_spec.rb +16 -3
- data/spec/models/scimitar/resources/mixin_spec.rb +781 -124
- data/spec/models/scimitar/schema/attribute_spec.rb +22 -0
- data/spec/models/scimitar/schema/user_spec.rb +2 -2
- data/spec/requests/active_record_backed_resources_controller_spec.rb +723 -40
- data/spec/requests/engine_spec.rb +75 -0
- data/spec/spec_helper.rb +10 -2
- data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +108 -0
- metadata +42 -34
@@ -93,14 +93,23 @@ module Scimitar
|
|
93
93
|
end
|
94
94
|
|
95
95
|
def valid_simple_type?(value)
|
96
|
-
|
97
|
-
|
98
|
-
(type
|
99
|
-
|
100
|
-
|
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]
|
117
|
-
|
118
|
-
|
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
|
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'
|
10
|
-
Attribute.new(name: 'givenName', type: 'string'
|
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',
|
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',
|
39
|
-
Attribute.new(name: 'phoneNumbers',
|
40
|
-
Attribute.new(name: 'ims',
|
41
|
-
Attribute.new(name: 'photos',
|
42
|
-
Attribute.new(name: 'addresses',
|
43
|
-
Attribute.new(name: 'groups',
|
44
|
-
Attribute.new(name: 'entitlements',
|
45
|
-
Attribute.new(name: 'roles',
|
46
|
-
Attribute.new(name: 'x509Certificates',
|
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.
|
42
|
-
#
|
43
|
-
# verification
|
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
|
data/lib/scimitar/engine.rb
CHANGED
@@ -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
|
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
|
-
|
59
|
-
|
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
|
27
|
-
|
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
|
63
|
-
key.kind_of?(Symbol) ? key.name
|
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
|
67
|
-
key.kind_of?(Symbol) ? key.to_s
|
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?
|
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
|
-
|
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
|
data/lib/scimitar/version.rb
CHANGED
@@ -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.
|
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 = '
|
11
|
+
DATE = '2025-03-05'
|
12
12
|
|
13
13
|
end
|
data/lib/scimitar.rb
CHANGED
@@ -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
|