scimitar 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (106) hide show
  1. checksums.yaml +7 -0
  2. data/Rakefile +16 -0
  3. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +180 -0
  4. data/app/controllers/scimitar/application_controller.rb +129 -0
  5. data/app/controllers/scimitar/resource_types_controller.rb +28 -0
  6. data/app/controllers/scimitar/resources_controller.rb +203 -0
  7. data/app/controllers/scimitar/schemas_controller.rb +16 -0
  8. data/app/controllers/scimitar/service_provider_configurations_controller.rb +8 -0
  9. data/app/models/scimitar/authentication_error.rb +9 -0
  10. data/app/models/scimitar/authentication_scheme.rb +18 -0
  11. data/app/models/scimitar/bulk.rb +8 -0
  12. data/app/models/scimitar/complex_types/address.rb +18 -0
  13. data/app/models/scimitar/complex_types/base.rb +41 -0
  14. data/app/models/scimitar/complex_types/email.rb +12 -0
  15. data/app/models/scimitar/complex_types/entitlement.rb +12 -0
  16. data/app/models/scimitar/complex_types/ims.rb +12 -0
  17. data/app/models/scimitar/complex_types/name.rb +12 -0
  18. data/app/models/scimitar/complex_types/phone_number.rb +12 -0
  19. data/app/models/scimitar/complex_types/photo.rb +12 -0
  20. data/app/models/scimitar/complex_types/reference_group.rb +12 -0
  21. data/app/models/scimitar/complex_types/reference_member.rb +12 -0
  22. data/app/models/scimitar/complex_types/role.rb +12 -0
  23. data/app/models/scimitar/complex_types/x509_certificate.rb +12 -0
  24. data/app/models/scimitar/engine_configuration.rb +24 -0
  25. data/app/models/scimitar/error_response.rb +20 -0
  26. data/app/models/scimitar/errors.rb +14 -0
  27. data/app/models/scimitar/filter.rb +11 -0
  28. data/app/models/scimitar/filter_error.rb +22 -0
  29. data/app/models/scimitar/invalid_syntax_error.rb +9 -0
  30. data/app/models/scimitar/lists/count.rb +64 -0
  31. data/app/models/scimitar/lists/query_parser.rb +730 -0
  32. data/app/models/scimitar/meta.rb +7 -0
  33. data/app/models/scimitar/not_found_error.rb +10 -0
  34. data/app/models/scimitar/resource_invalid_error.rb +9 -0
  35. data/app/models/scimitar/resource_type.rb +29 -0
  36. data/app/models/scimitar/resources/base.rb +159 -0
  37. data/app/models/scimitar/resources/group.rb +13 -0
  38. data/app/models/scimitar/resources/mixin.rb +964 -0
  39. data/app/models/scimitar/resources/user.rb +13 -0
  40. data/app/models/scimitar/schema/address.rb +24 -0
  41. data/app/models/scimitar/schema/attribute.rb +123 -0
  42. data/app/models/scimitar/schema/base.rb +86 -0
  43. data/app/models/scimitar/schema/derived_attributes.rb +24 -0
  44. data/app/models/scimitar/schema/email.rb +10 -0
  45. data/app/models/scimitar/schema/entitlement.rb +10 -0
  46. data/app/models/scimitar/schema/group.rb +27 -0
  47. data/app/models/scimitar/schema/ims.rb +10 -0
  48. data/app/models/scimitar/schema/name.rb +20 -0
  49. data/app/models/scimitar/schema/phone_number.rb +10 -0
  50. data/app/models/scimitar/schema/photo.rb +10 -0
  51. data/app/models/scimitar/schema/reference_group.rb +23 -0
  52. data/app/models/scimitar/schema/reference_member.rb +21 -0
  53. data/app/models/scimitar/schema/role.rb +10 -0
  54. data/app/models/scimitar/schema/user.rb +52 -0
  55. data/app/models/scimitar/schema/vdtp.rb +18 -0
  56. data/app/models/scimitar/schema/x509_certificate.rb +22 -0
  57. data/app/models/scimitar/service_provider_configuration.rb +49 -0
  58. data/app/models/scimitar/supportable.rb +14 -0
  59. data/app/views/layouts/scimitar/application.html.erb +14 -0
  60. data/config/initializers/scimitar.rb +82 -0
  61. data/config/routes.rb +6 -0
  62. data/lib/scimitar.rb +23 -0
  63. data/lib/scimitar/engine.rb +63 -0
  64. data/lib/scimitar/version.rb +13 -0
  65. data/spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb +24 -0
  66. data/spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb +30 -0
  67. data/spec/apps/dummy/app/controllers/mock_groups_controller.rb +13 -0
  68. data/spec/apps/dummy/app/controllers/mock_users_controller.rb +13 -0
  69. data/spec/apps/dummy/app/models/mock_group.rb +83 -0
  70. data/spec/apps/dummy/app/models/mock_user.rb +104 -0
  71. data/spec/apps/dummy/config/application.rb +17 -0
  72. data/spec/apps/dummy/config/boot.rb +2 -0
  73. data/spec/apps/dummy/config/environment.rb +2 -0
  74. data/spec/apps/dummy/config/environments/test.rb +15 -0
  75. data/spec/apps/dummy/config/initializers/cookies_serializer.rb +3 -0
  76. data/spec/apps/dummy/config/initializers/scimitar.rb +14 -0
  77. data/spec/apps/dummy/config/initializers/session_store.rb +3 -0
  78. data/spec/apps/dummy/config/routes.rb +24 -0
  79. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +15 -0
  80. data/spec/apps/dummy/db/migrate/20210308020313_create_mock_groups.rb +10 -0
  81. data/spec/apps/dummy/db/migrate/20210308044214_create_join_table_mock_groups_mock_users.rb +8 -0
  82. data/spec/apps/dummy/db/schema.rb +42 -0
  83. data/spec/controllers/scimitar/application_controller_spec.rb +173 -0
  84. data/spec/controllers/scimitar/resource_types_controller_spec.rb +94 -0
  85. data/spec/controllers/scimitar/resources_controller_spec.rb +247 -0
  86. data/spec/controllers/scimitar/schemas_controller_spec.rb +75 -0
  87. data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +22 -0
  88. data/spec/models/scimitar/complex_types/address_spec.rb +19 -0
  89. data/spec/models/scimitar/complex_types/email_spec.rb +23 -0
  90. data/spec/models/scimitar/lists/count_spec.rb +147 -0
  91. data/spec/models/scimitar/lists/query_parser_spec.rb +763 -0
  92. data/spec/models/scimitar/resource_type_spec.rb +21 -0
  93. data/spec/models/scimitar/resources/base_spec.rb +289 -0
  94. data/spec/models/scimitar/resources/base_validation_spec.rb +61 -0
  95. data/spec/models/scimitar/resources/mixin_spec.rb +2127 -0
  96. data/spec/models/scimitar/resources/user_spec.rb +55 -0
  97. data/spec/models/scimitar/schema/attribute_spec.rb +80 -0
  98. data/spec/models/scimitar/schema/base_spec.rb +64 -0
  99. data/spec/models/scimitar/schema/group_spec.rb +87 -0
  100. data/spec/models/scimitar/schema/user_spec.rb +710 -0
  101. data/spec/requests/active_record_backed_resources_controller_spec.rb +569 -0
  102. data/spec/requests/application_controller_spec.rb +49 -0
  103. data/spec/requests/controller_configuration_spec.rb +17 -0
  104. data/spec/requests/engine_spec.rb +20 -0
  105. data/spec/spec_helper.rb +66 -0
  106. metadata +315 -0
@@ -0,0 +1,7 @@
1
+ module Scimitar
2
+ class Meta
3
+ include ActiveModel::Model
4
+
5
+ attr_accessor :resourceType, :created, :lastModified, :location, :version
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ module Scimitar
2
+
3
+ class NotFoundError < ErrorResponse
4
+
5
+ def initialize(id)
6
+ super(status: 404, detail: "Resource #{id.inspect} not found")
7
+ end
8
+
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ module Scimitar
2
+ class ResourceInvalidError < ErrorResponse
3
+
4
+ def initialize(error_message)
5
+ super(status: 400, scimType: 'invalidValue', detail:"Operation failed since record has become invalid: #{error_message}")
6
+ end
7
+
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ module Scimitar
2
+ # Provides info about a resource type. Instances of this class are used to provide info through the /ResourceTypes endpoint of a SCIM service provider.
3
+ class ResourceType
4
+ include ActiveModel::Model
5
+ attr_accessor :meta, :endpoint, :schema, :schemas, :id, :name, :schemaExtensions
6
+
7
+ def initialize(attributes = {})
8
+ default_attributes = {
9
+ meta: Meta.new(
10
+ 'resourceType': 'ResourceType'
11
+ ),
12
+ schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType']
13
+ }
14
+ super(default_attributes.merge(attributes))
15
+ end
16
+
17
+
18
+ def as_json(options = {})
19
+ without_extensions = super(except: 'schemaExtensions')
20
+ if schemaExtensions.present?
21
+ extensions = schemaExtensions.map{|extension| {"schema" => extension, "required" => false}}
22
+ without_extensions.merge('schemaExtensions' => extensions)
23
+ else
24
+ without_extensions
25
+ end
26
+ end
27
+
28
+ end
29
+ end
@@ -0,0 +1,159 @@
1
+ module Scimitar
2
+ module Resources
3
+ # The base class for all SCIM resources.
4
+ class Base
5
+ include ActiveModel::Model
6
+ include Scimitar::Schema::DerivedAttributes
7
+ include Scimitar::Errors
8
+
9
+ attr_accessor :id, :externalId, :meta
10
+ attr_reader :errors
11
+ validate :validate_resource
12
+
13
+ def initialize(options = {})
14
+ flattended_attributes = flatten_extension_attributes(options)
15
+ attributes = flattended_attributes.with_indifferent_access.slice(*self.class.all_attributes)
16
+ super(attributes)
17
+ constantize_complex_types(attributes)
18
+ @errors = ActiveModel::Errors.new(self)
19
+ end
20
+
21
+ def flatten_extension_attributes(options)
22
+ flattened = options.dup
23
+ self.class.extended_schemas.each do |extended_schema|
24
+ if extension_attrs = flattened.delete(extended_schema.id)
25
+ flattened.merge!(extension_attrs)
26
+ end
27
+ end
28
+ flattened
29
+ end
30
+
31
+ # Can be used to extend an existing resource type's schema. For example:
32
+ #
33
+ # module Scim
34
+ # module Schema
35
+ # class MyExtension < Scimitar::Schema::Base
36
+ #
37
+ # def initialize(options = {})
38
+ # super(name: 'ExtendedGroup',
39
+ # id: self.class.id,
40
+ # description: 'Represents extra info about a group',
41
+ # scim_attributes: self.class.scim_attributes)
42
+ # end
43
+ #
44
+ # def self.id
45
+ # 'urn:ietf:params:scim:schemas:extension:extendedgroup:2.0:Group'
46
+ # end
47
+ #
48
+ # def self.scim_attributes
49
+ # [Scimitar::Schema::Attribute.new(name: 'someAddedAttribute',
50
+ # type: 'string',
51
+ # required: true,
52
+ # canonicalValues: ['FOO', 'BAR'])]
53
+ # end
54
+ # end
55
+ # end
56
+ # end
57
+ #
58
+ # Scimitar::Resources::Group.extend_schema Scim::Schema::MyExtension
59
+ #
60
+ def self.extend_schema(schema)
61
+ derive_attributes_from_schema(schema)
62
+ extended_schemas << schema
63
+ end
64
+
65
+ def self.extended_schemas
66
+ @extended_schemas ||= []
67
+ end
68
+
69
+ def self.schemas
70
+ ([schema] + extended_schemas).flatten
71
+ end
72
+
73
+ def self.all_attributes
74
+ scim_attributes = schemas.map(&:scim_attributes).flatten.map(&:name)
75
+ scim_attributes + [:id, :externalId, :meta]
76
+ end
77
+
78
+ # Calls to Scimitar::Schema::Base::find_attribute for each of the schemas
79
+ # in ::schemas, in order returned (so main schema would be first, then
80
+ # any extended schemas searched next). Returns the first match found, or
81
+ # +nil+.
82
+ #
83
+ # See Scimitar::Schema::Base::find_attribute for details on parameters,
84
+ # more about the return value and other general information.
85
+ #
86
+ def self.find_attribute(*path)
87
+ found_attribute = nil
88
+
89
+ self.schemas.each do | schema |
90
+ found_attribute = schema.find_attribute(*path)
91
+ break unless found_attribute.nil?
92
+ end
93
+
94
+ return found_attribute
95
+ end
96
+
97
+ def self.complex_scim_attributes
98
+ schema.scim_attributes.select(&:complexType).group_by(&:name)
99
+ end
100
+
101
+ def complex_type_from_hash(scim_attribute, attr_value)
102
+ if attr_value.is_a?(Hash)
103
+ scim_attribute.complexType.new(attr_value)
104
+ else
105
+ attr_value
106
+ end
107
+ end
108
+
109
+ def constantize_complex_types(hash)
110
+ hash.with_indifferent_access.each_pair do |attr_name, attr_value|
111
+ scim_attribute = self.class.complex_scim_attributes[attr_name].try(:first)
112
+ if scim_attribute && scim_attribute.complexType
113
+ if scim_attribute.multiValued
114
+ self.send("#{attr_name}=", attr_value.map {|attr_for_each_item| complex_type_from_hash(scim_attribute, attr_for_each_item)})
115
+ else
116
+ self.send("#{attr_name}=", complex_type_from_hash(scim_attribute, attr_value))
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ def as_json(options = {})
123
+ self.meta = Meta.new unless self.meta
124
+ meta.resourceType = self.class.resource_type_id
125
+ original_hash = super(options).except('errors')
126
+ original_hash.merge!('schemas' => self.class.schemas.map(&:id))
127
+ self.class.extended_schemas.each do |extension_schema|
128
+ extension_attributes = extension_schema.scim_attributes.map(&:name)
129
+ original_hash.merge!(extension_schema.id => original_hash.extract!(*extension_attributes))
130
+ end
131
+ original_hash
132
+ end
133
+
134
+ def self.resource_type_id
135
+ name.demodulize
136
+ end
137
+
138
+ def self.resource_type(location)
139
+ resource_type = ResourceType.new(
140
+ endpoint: endpoint,
141
+ schema: schema.id,
142
+ id: resource_type_id,
143
+ name: resource_type_id,
144
+ schemaExtensions: extended_schemas.map(&:id)
145
+ )
146
+
147
+ resource_type.meta.location = location
148
+ resource_type
149
+ end
150
+
151
+ def validate_resource
152
+ self.class.schema.valid?(self)
153
+ self.class.extended_schemas.each do |extended_schema|
154
+ extended_schema.valid?(self)
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,13 @@
1
+ module Scimitar
2
+ module Resources
3
+ class Group < Base
4
+
5
+ set_schema Schema::Group
6
+
7
+ def self.endpoint
8
+ '/Groups'
9
+ end
10
+
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,964 @@
1
+ module Scimitar
2
+ module Resources
3
+
4
+ # The mixin included by any class in your application which is to be mapped
5
+ # to and exposed via a SCIM interface. Any one such class must have one
6
+ # corresponding ResourcesController subclass declaring its association to
7
+ # that model.
8
+ #
9
+ # Your class becomes responsible for implementing various *class methods*
10
+ # as described below. YOU MUST DECLARE THESE **BEFORE** YOU INCLUDE THE
11
+ # MIXIN MODULE because Ruby parses classes top-down and the mixin checks to
12
+ # make sure that required methods exist, so these must be defined *first*.
13
+ #
14
+ #
15
+ #
16
+ # == scim_resource_type
17
+ #
18
+ # Define this method to return the Scimitar resource class that corresponds
19
+ # to the mixing-in class.
20
+ #
21
+ # For example, if you have an ActiveRecord "User" class that maps to a SCIM
22
+ # "User" resource type:
23
+ #
24
+ # def self.scim_resource_type
25
+ # return Scimitar::Resources::User
26
+ # end
27
+ #
28
+ # This is used to render SCIM JSON data via #to_scim.
29
+ #
30
+ #
31
+ #
32
+ # == scim_attributes_map
33
+ #
34
+ # Define this method to return a Hash that maps SCIM attributes to
35
+ # corresponding supported accessor methods in the mixing-in class.
36
+ #
37
+ # Define read-only, write-only or read-write attributes here. Scimitar will
38
+ # check for an appropriate accessor depending on whether SCIM operations
39
+ # are read or write and acts accordingly. At each level of the Ruby Hash,
40
+ # the keys are case-sensitive attributes from the SCIM schema and values
41
+ # are either Symbols, giving a corresponding read/write accessor name in
42
+ # the mixing-in class, Hashes for nested SCIM schema data as shown below or
43
+ # for Array entries, special structures described later.
44
+ #
45
+ # For example, for a User model <-> SCIM user:
46
+ #
47
+ # def self.scim_attributes_map
48
+ # return {
49
+ # id: :id,
50
+ # externalId: :scim_external_id,
51
+ # userName: :username,
52
+ # name: {
53
+ # givenName: :given_name,
54
+ # familyName: :last_name
55
+ # },
56
+ # active: :is_active?
57
+ # }
58
+ # end
59
+ #
60
+ # Note that providing storage and filter (search) support for externalId is
61
+ # VERY STRONGLY recommended (bordering on mandatory) for your service to
62
+ # provide adequate support for typical clients to function smoothly. See
63
+ # "scim_queryable_attributes" below for filtering.
64
+ #
65
+ # This omits things like "email" because in SCIM those are specified in an
66
+ # Array, where each entry has a "type" field - e.g. "home", "work". Within
67
+ # SCIM this is common but there are also just free lists of data, such as
68
+ # the list of Members in a Group. This makes the mapping description more
69
+ # complex. You can provide two kinds of mapping data:
70
+ #
71
+ # * One where a specific SCIM attribute is present in each array entry and
72
+ # can contain only a set of specific, discrete values; your mapping
73
+ # defines entries for each value of interest. E-mail is an example here,
74
+ # where "type" is the SCIM attribute and you might map "work" and "home".
75
+ #
76
+ # For discrete matches, you declare the Array containing Hashes with key
77
+ # "match", where the value gives the name of the SCIM attribute to read or
78
+ # write for each array entry; "with", where the value gives the thing to
79
+ # match at this attribute; then "using", where the value is a Hash giving
80
+ # a mapping schema just as described herein (schema can nest as deeply as
81
+ # you like).
82
+ #
83
+ # Given that e-mails in SCIM look something like this:
84
+ #
85
+ # "emails": [
86
+ # {
87
+ # "value": "bjensen@example.com",
88
+ # "type": "work",
89
+ # "primary": true
90
+ # },
91
+ # {
92
+ # "value": "babs@jensen.org",
93
+ # "type": "home"
94
+ # }
95
+ # ]
96
+ #
97
+ # ...then we could extend the above attributes map example thus:
98
+ #
99
+ # def self.scim_attributes_map
100
+ # # ...
101
+ # emails: [
102
+ # {
103
+ # match: 'type',
104
+ # with: 'work',
105
+ # using: {
106
+ # value: :work_email_address,
107
+ # primary: true
108
+ # }
109
+ # },
110
+ # {
111
+ # match: 'type',
112
+ # with: 'home',
113
+ # using: { value: :home_email_address }
114
+ # }
115
+ # ],
116
+ # # ...
117
+ # end
118
+ #
119
+ # ...where the including class would have a #work_email_address accessor
120
+ # and we're hard-coding this as the primary (preferred) address (but could
121
+ # just as well map this to another accessor, e.g. :work_email_is_primary?).
122
+ #
123
+ # * One where a SCIM array contains just a list of arbitrary entries, each
124
+ # with a known schema, and these map attribute-by-attribute to same-index
125
+ # items in a corresponding array in the mixing-in model. Group members
126
+ # are the example use case here.
127
+ #
128
+ # For things like a group's list of members, again include an array in the
129
+ # attribute map as above but this time have a key "list" with a value that
130
+ # is the attribute accessor in your mixing in model that returns an
131
+ # Enumerable of values to map, then as above, "using" which provides the
132
+ # nested schema saying how each of those objects should be mapped.
133
+ #
134
+ # Suppose you were mixing this module into a Team class and there was an
135
+ # association Team#users that provided an Enumerable of team member User
136
+ # objects:
137
+ #
138
+ # def self.scim_attributes_map
139
+ # # ...
140
+ # groups: [
141
+ # {
142
+ # list: :users, # <-- i.e. Team.users,
143
+ # using: {
144
+ # value: :id, # <-- i.e. Team.users[n].id
145
+ # display: :full_name # <-- i.e. Team.users[n].full_name
146
+ # },
147
+ # find_with: -> (scim_list_entry) {...} # See below
148
+ # }
149
+ # ],
150
+ # #...
151
+ # end
152
+ #
153
+ # The mixing-in class _must+ implement the read accessor identified by the
154
+ # value of the "list" key, returning any indexed, Enumerable collection
155
+ # (e.g. an Array or ActiveRecord::Relation instance). The optional key
156
+ # ":find_with" is defined with a Proc that's passed the SCIM entry at each
157
+ # list position. It must use this to look up the equivalent entry for
158
+ # association via the write accessor described by the ":list" key. In the
159
+ # example above, "find_with"'s Proc might look at a SCIM entry value which
160
+ # is expected to be a user ID and find that User. The mapped set of User
161
+ # data thus found would be written back with "#users=", due to the ":list"
162
+ # key declaring the method name ":users".
163
+ #
164
+ # Note that you can only use either:
165
+ #
166
+ # * One or more static maps where each matches some other piece of source
167
+ # SCIM data field value, so that specific SCIM array entries are matched
168
+ #
169
+ # * A single dynamic list entry which maps app SCIM array entries.
170
+ #
171
+ # A mixture of static and dynamic data, or multiple dynamic entries in a
172
+ # single mapping array value will produce undefined behaviour.
173
+ #
174
+ #
175
+ #
176
+ # == scim_mutable_attributes
177
+ #
178
+ # Define this method to return a Set (preferred) or Array of names of
179
+ # attributes which may be written in the mixing-in class.
180
+ #
181
+ # If you return +nil+, it is assumed that +any+ attribute mapped by
182
+ # ::scim_attributes_map which has a write accessor will be eligible for
183
+ # assignment during SCIM creation or update operations.
184
+ #
185
+ # For example, if everything in ::scim_attributes_map with a write accessor
186
+ # is to be mutable over SCIM:
187
+ #
188
+ # def self.scim_mutable_attributes
189
+ # return nil
190
+ # end
191
+ #
192
+ # Note that as a common special case, any mapped attribute of the Symbol
193
+ # value ":id" will be removed from the list, as it is assumed to be e.g. a
194
+ # primary key or similar. So, even though it'll have a write accessor, it
195
+ # is not something that should be mutable over SCIM - it's taken to be your
196
+ # internal record ID. If you do want :id included as mutable or if you have
197
+ # a different primary key attribute name, you'll just need to return the
198
+ # mutable attribute list directly in your ::scim_mutable_attributes method
199
+ # rather than relying on the list extracted from ::scim_attributes_map.
200
+ #
201
+ #
202
+ # == scim_queryable_attributes
203
+ #
204
+ # Define this method to return a Hash that maps field names you wish to
205
+ # support in SCIM filter queries to corresponding attributes in the in the
206
+ # mixing-in class. If +nil+ then filtering is not supported in the
207
+ # ResouceController subclass which declares that it maps to the mixing-in
208
+ # class. If not +nil+ but a SCIM filter enquiry is made for an unmapped
209
+ # attribute, an 'invalid filter' exception is raised.
210
+ #
211
+ # If using ActiveRecord support in Scimitar::Lists::QueryParser, the mapped
212
+ # entites are columns and that's expressed in the names of keys described
213
+ # below; if you have other approaches to searching, these might be virtual
214
+ # attributes or other such constructs rather than columns. That would be up
215
+ # to your non-ActiveRecord's implementation to decide.
216
+ #
217
+ # Each STRING field name(s) represents a *flat* attribute path that might
218
+ # be encountered in a filter - e.g. "name.familyName", "emails.value" (and
219
+ # often it makes sense to define "emails" and "emails.value" identically to
220
+ # allow for different client searching "styles", given ambiguities in RFC
221
+ # 7644 filter examples).
222
+ #
223
+ # Each value is a Hash with Symbol keys ':column', naming just one simple
224
+ # column for a mapping; ':columns', with an Array of column names that you
225
+ # want to map using 'OR' for a single search on the corresponding SCIM
226
+ # attribute; or ':ignore' with value 'true', which means that a fitler on
227
+ # the matching attribute is ignored rather than resulting in an "invalid
228
+ # filter" exception - beware possibilities for surprised clients getting a
229
+ # broader result set than expected. Example:
230
+ #
231
+ # def self.scim_queryable_attributes
232
+ # return {
233
+ # 'name.givenName' => { column: :first_name },
234
+ # 'name.familyName' => { column: :last_name },
235
+ # 'emails' => { columns: [ :work_email_address, :home_email_address ] },
236
+ # 'emails.value' => { columns: [ :work_email_address, :home_email_address ] },
237
+ # 'emails.type' => { ignore: true }
238
+ # }
239
+ # end
240
+ #
241
+ # Filtering is currently limited and searching within e.g. arrays of data
242
+ # is not supported; only simple top-level keys can be mapped.
243
+ #
244
+ #
245
+ # == Optional methods
246
+ #
247
+ # === scim_timestamps_map
248
+ #
249
+ # If you implement this class method, it should return a Hash with one or
250
+ # both of the keys 'created' and 'lastModified', as Symbols. The values
251
+ # should be methods that the including method supports which return a
252
+ # creation or most-recently-updated time, respectively. The returned object
253
+ # mustsupport #iso8601 to convert to a String representation. Example for a
254
+ # typical ActiveRecord object with standard timestamps:
255
+ #
256
+ # def self.scim_timestamps_map
257
+ # {
258
+ # created: :created_at,
259
+ # lastModified: :updated_at
260
+ # }
261
+ # end
262
+ #
263
+ module Mixin
264
+ extend ActiveSupport::Concern
265
+
266
+ included do
267
+ %w{
268
+ scim_resource_type
269
+ scim_attributes_map
270
+ scim_mutable_attributes
271
+ scim_queryable_attributes
272
+ }.each do | required_class_method_name |
273
+ raise "You must define ::#{required_class_method_name} in #{self}" unless self.respond_to?(required_class_method_name)
274
+ end
275
+
276
+ # An instance-level method which calls ::scim_mutable_attributes and
277
+ # either uses its returned array of mutable attribute names or reads
278
+ # ::scim_attributes_map and determines the list from that. Caches
279
+ # the result in an instance variable.
280
+ #
281
+ def scim_mutable_attributes
282
+ @scim_mutable_attributes ||= self.class.scim_mutable_attributes()
283
+
284
+ if @scim_mutable_attributes.nil?
285
+ @scim_mutable_attributes = Set.new
286
+
287
+ # Variant of https://stackoverflow.com/a/49315255
288
+ #
289
+ extractor = ->(outer_enum) do
290
+ outer_enum.each do |key, value|
291
+ enum = [key, value].detect(&Enumerable.method(:===))
292
+ if enum.nil?
293
+ @scim_mutable_attributes << value if value.is_a?(Symbol) && self.respond_to?("#{value}=")
294
+ else
295
+ if enum.is_a?(Hash)
296
+ extractor.call(enum)
297
+ elsif enum.is_a?(Array)
298
+ enum.each do | static_or_dynamic_mapping |
299
+ if static_or_dynamic_mapping.key?(:match) # Static
300
+ extractor.call(static_or_dynamic_mapping[:using])
301
+ elsif static_or_dynamic_mapping.key?(:find_with) # Dynamic
302
+ @scim_mutable_attributes << static_or_dynamic_mapping[:list]
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end
309
+
310
+ extractor.call(self.class.scim_attributes_map())
311
+ @scim_mutable_attributes.delete(:id)
312
+ end
313
+
314
+ @scim_mutable_attributes
315
+ end
316
+
317
+ # An instance level method which calls ::scim_queryable_attributes and
318
+ # caches the result in an instance variable, for symmetry with
319
+ # #scim_mutable_attributes and to permit potential future enhancements
320
+ # for how the return value of ::scim_queryable_attributes is handled.
321
+ #
322
+ def scim_queryable_attributes
323
+ @scim_queryable_attributes ||= self.class.scim_queryable_attributes()
324
+ end
325
+
326
+ # Render self as a SCIM object using ::scim_attributes_map.
327
+ #
328
+ # +location+:: The location (HTTP(S) full URI) of this resource, in the
329
+ # domain of the object including this mixin - "your" IDs,
330
+ # not the remote SCIM client's external IDs. #url_for is a
331
+ # good way to generate this.
332
+ #
333
+ def to_scim(location:)
334
+ map = self.class.scim_attributes_map()
335
+ timestamps_map = self.class.scim_timestamps_map() if self.class.respond_to?(:scim_timestamps_map)
336
+ attrs_hash = self.to_scim_backend(data_source: self, attrs_map_or_leaf_value: map)
337
+ resource = self.class.scim_resource_type().new(attrs_hash)
338
+ meta_attrs_hash = { location: location }
339
+
340
+ meta_attrs_hash[:created ] = self.send(timestamps_map[:created ])&.iso8601(0) if timestamps_map&.key?(:created)
341
+ meta_attrs_hash[:lastModified] = self.send(timestamps_map[:lastModified])&.iso8601(0) if timestamps_map&.key?(:lastModified)
342
+
343
+ resource.meta = Meta.new(meta_attrs_hash)
344
+ return resource
345
+ end
346
+
347
+ # Update self from a SCIM object using ::scim_attributes_map. This does
348
+ # NOT PERSIST ("save") 'this' instance - it just sets attribute values
349
+ # within it.
350
+ #
351
+ # If you are mixing into an ActiveRecord subclass then depending on how
352
+ # your ::scim_attributes_map updates associated objects (if any), Rails
353
+ # might make database writes to update those associations immediately.
354
+ # Given this, it is highly recommended that you wrap calls to this
355
+ # method and your subsequent save of 'self' inside a transaction.
356
+ #
357
+ # ActiveRecord::Base.transaction do
358
+ # record.from_scim!(scim_hash: some_payload)
359
+ # record.save!
360
+ # end
361
+ #
362
+ # Call ONLY for POST or PUT. For PATCH, see #from_scim_patch!.
363
+ #
364
+ # +scim_hash+:: A Hash that's the result of parsing a JSON payload
365
+ # from an inbound POST or PUT request.
366
+ #
367
+ # Returns 'self', for convenience of e.g. chaining other methods.
368
+ #
369
+ def from_scim!(scim_hash:)
370
+ scim_hash.freeze()
371
+ map = self.class.scim_attributes_map().freeze()
372
+
373
+ self.from_scim_backend!(attrs_map_or_leaf_value: map, scim_hash_or_leaf_value: scim_hash)
374
+ return self
375
+ end
376
+
377
+ # Update self from a SCIM object representing a PATCH operation. This
378
+ # does NOT PERSIST ("save") 'this' instance - it just sets attribute
379
+ # values within it.
380
+ #
381
+ # SCIM patch operations are complex. A series of operations is given,
382
+ # each asking to add, remove or replace specific attributes or, via
383
+ # filters, potentially multiple attributes if the filter matches many.
384
+ #
385
+ # Pass the PATCH payload. Then:
386
+ #
387
+ # * This instance (self) is converted to a SCIM representation via
388
+ # calling #to_scim.
389
+ #
390
+ # * The inbound operations are applied. A Scimitar::ErrorResponse may
391
+ # be thrown if the patch data looks bad - if you are calling from a
392
+ # Scimitar::ActiveRecordBackedResourcesController subclass, this will
393
+ # be handled for you and returned as an appropriate HTTP response.
394
+ # Otherwise, you'll need to rescue it yourself and e.g. make use of
395
+ # Scimitar::ApplicationController#handle_scim_error, passing the
396
+ # exception object to it, if you are a subclass of that base class.
397
+ #
398
+ # * The (possibly) updated SCIM representation of 'self' is pushed
399
+ # back into 'this' instance via #from_scim!.
400
+ #
401
+ # IMPORTANT: Please see #from_scim! for notes about associations and
402
+ # use of transactions with ActiveRecord.
403
+ #
404
+ # Call ONLY for PATCH. For POST and PUT, see #from_scim!.
405
+ #
406
+ def from_scim_patch!(patch_hash:)
407
+ patch_hash.freeze()
408
+ scim_hash = self.to_scim(location: '(unused)').as_json()
409
+
410
+ patch_hash['Operations'].each do |operation|
411
+ nature = operation['op' ]&.downcase
412
+ path_str = operation['path' ]
413
+ value = operation['value']
414
+
415
+ unless ['add', 'remove', 'replace'].include?(nature)
416
+ raise Scimitar::InvalidSyntaxError.new("Unrecognised PATCH \"op\" value of \"#{nature}\"")
417
+ end
418
+
419
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.2
420
+ #
421
+ # o If "path" is unspecified, the operation fails with HTTP status
422
+ # code 400 and a "scimType" error code of "noTarget".
423
+ #
424
+ # (...for "add" or "replace", no path means "whole object").
425
+ #
426
+ if nature == 'remove' && path_str.blank?
427
+ raise Scimitar::ErrorResponse.new(
428
+ status: 400,
429
+ scimType: 'noTarget',
430
+ detail: 'No "path" target given for "replace" operation'
431
+ )
432
+ end
433
+
434
+ # Deal with the exception case of no path, where the entire object
435
+ # is addressed. It's easier internally to treat a path as a set of
436
+ # steps towards a final Hash key (attribute) with an associated
437
+ # value to change (and filters may apply if the value is an Array).
438
+ #
439
+ extract_root = false
440
+ if path_str.blank?
441
+ extract_root = true
442
+ path_str = 'root'
443
+ scim_hash = { 'root' => scim_hash }
444
+ end
445
+
446
+ self.from_patch_backend!(
447
+ nature: nature,
448
+ path: (path_str || '').split('.'),
449
+ value: value,
450
+ altering_hash: scim_hash
451
+ )
452
+
453
+ if extract_root
454
+ scim_hash = scim_hash['root']
455
+ end
456
+ end
457
+
458
+ self.from_scim!(scim_hash: scim_hash)
459
+ return self
460
+ end
461
+
462
+ private # (...but note that we're inside "included do" within a mixin)
463
+
464
+ # A recursive method that takes a Hash mapping SCIM attributes to the
465
+ # mixing in class's attributes and via ::scim_attributes_map replaces
466
+ # symbols in the schema with the corresponding value from the user.
467
+ #
468
+ # Given a schema with symbols, this method will search through the
469
+ # object for the symbols, send those symbols to the model and replace
470
+ # the symbol with the return value.
471
+ #
472
+ # +data_source+:: The source of data. At the top level,
473
+ # this is "self" (an instance of the
474
+ # class mixing in this module).
475
+ #
476
+ # +attrs_map_or_leaf_value+:: The attribute map. At the top level,
477
+ # this is from ::scim_attributes_map.
478
+ #
479
+ def to_scim_backend(data_source:, attrs_map_or_leaf_value:)
480
+ case attrs_map_or_leaf_value
481
+ when Hash # Expected at top-level of any map, or nested within
482
+ attrs_map_or_leaf_value.each.with_object({}) do |(key, value), hash|
483
+ hash[key] = to_scim_backend(data_source: data_source, attrs_map_or_leaf_value: value)
484
+ end
485
+
486
+ when Array # Static or dynamic mapping against lists in data source
487
+ built_dynamic_list = false
488
+ mapped_array = attrs_map_or_leaf_value.map do |value|
489
+ if ! value.is_a?(Hash)
490
+ raise 'Bad attribute map: Array contains someting other than mapping Hash(es)'
491
+
492
+ elsif value.key?(:match) # Static map
493
+ static_hash = { value[:match] => value[:with] }
494
+ static_hash.merge!(to_scim_backend(data_source: data_source, attrs_map_or_leaf_value: value[:using]))
495
+ static_hash
496
+
497
+ elsif value.key?(:list) # Dynamic mapping of each complex list item
498
+ built_dynamic_list = true
499
+ list = data_source.public_send(value[:list])
500
+ list.map do |list_entry|
501
+ to_scim_backend(data_source: list_entry, attrs_map_or_leaf_value: value[:using])
502
+ end
503
+
504
+ else # Unknown type, just treat as flat values
505
+ raise 'Bad attribute map: Mapping Hash inside Array does not contain supported data'
506
+
507
+ end
508
+ end
509
+
510
+ # If a dynamic list was generated, it's sitting as a nested
511
+ # Array in the first index of the mapped result; pull it out.
512
+ #
513
+ mapped_array = mapped_array.first if built_dynamic_list
514
+ mapped_array
515
+
516
+ when Symbol # Leaf node, Symbol -> reader method to call on data source
517
+ if data_source.respond_to?(attrs_map_or_leaf_value) # A read-accessor exists?
518
+ value = data_source.public_send(attrs_map_or_leaf_value)
519
+ value = value.to_s if value.is_a?(Numeric)
520
+ value
521
+ else
522
+ nil
523
+ end
524
+
525
+ else # Leaf node, other type -> literal static value to use
526
+ attrs_map_or_leaf_value
527
+ end
528
+ end
529
+
530
+ # Given a SCIM resource representation (left) and an attribute map to
531
+ # an instance of the mixin-including class / 'self' (right), walk the
532
+ # attribute map, looking up equivalent values in the SCIM resource.
533
+ # Mutable attributes will be set from the SCIM data, or cleared if
534
+ # the SCIM data has nothing set ("PUT" semantics; splat resource data
535
+ # in full, writing all mapped attributes).
536
+ #
537
+ # * Literal map values like 'true' are for read-time uses; ignored.
538
+ # * Symbol map values are treated as read accessor method names and a
539
+ # write accessor checked for by adding "=". If this method exists,
540
+ # a value write is attempted using the SCIM resource data.
541
+ # * Static and dynamic array mappings perform as documented for
542
+ # ::scim_attributes_map.
543
+ #
544
+ # { | {
545
+ # "userName": "foo", | "id": "id",
546
+ # "name": { | "externalId": :scim_uid",
547
+ # "givenName": "Foo", | "userName": :username",
548
+ # "familyName": "Bar" | "name": {
549
+ # }, | "givenName": :first_name",
550
+ # "active": true, | "familyName": :last_name"
551
+ # "emails": [ | },
552
+ # { | "emails": [
553
+ # "type": "work", <------\ | {
554
+ # "primary": true, \------+--- "match": "type",
555
+ # "value": "foo.bar@test.com" | "with": "work",
556
+ # } | "using": {
557
+ # ], | "value": :work_email_address",
558
+ # "phoneNumbers": [ | "primary": true
559
+ # { | }
560
+ # "type": "work", | }
561
+ # "primary": false, | ],
562
+ # "value": "+642201234567" | groups: [
563
+ # } | {
564
+ # ], | list: :groups,
565
+ # "id": "42", | using: {
566
+ # "externalId": "AA02984", | value: :id,
567
+ # "meta": { | display: :full_name
568
+ # "location": "https://test.com/mock_users/42", | }
569
+ # "resourceType": "User" | }
570
+ # }, | ],
571
+ # "schemas": [ | "active": :is_active"
572
+ # "urn:ietf:params:scim:schemas:core:2.0:User" | }
573
+ # ] |
574
+ # } |
575
+ #
576
+ # Named parameters:
577
+ #
578
+ # +attrs_map_or_leaf_value+:: Attribute map; recursive calls just
579
+ # pass in the fragment for recursion, so
580
+ # at the deepest level, this ends up
581
+ # being a leaf node which may have a
582
+ # Symbol method name, used to look for a
583
+ # write accessor; or a read-only literal,
584
+ # which is ignored (right hand side of
585
+ # the ASCII art diagram).
586
+ #
587
+ # +scim_hash_or_leaf_value+:: Similar to +attrs_map_or_leaf_value+
588
+ # but tracks the SCIM schema data being
589
+ # read as input source material (left
590
+ # hand side of the ASCII art diagram).
591
+ #
592
+ # +path+:: Array of SCIM attribute names giving a
593
+ # path into the SCIM schema where
594
+ # iteration has reached. Used to find the
595
+ # schema attribute definiton and check
596
+ # mutability before writing.
597
+ #
598
+ def from_scim_backend!(
599
+ attrs_map_or_leaf_value:,
600
+ scim_hash_or_leaf_value:,
601
+ path: []
602
+ )
603
+ attrs_map_or_leaf_value = attrs_map_or_leaf_value.with_indifferent_access() if attrs_map_or_leaf_value.instance_of?(Hash)
604
+
605
+ # We get the schema via this instance's class's resource type, even
606
+ # if we end up in collections of other types - because it's *this*
607
+ # schema at the top level that defines the attributes of interest
608
+ # within any collections, not SCIM schema - if any - for the items
609
+ # within the collection (a User's "groups" per-array-entry schema
610
+ # is quite different from the Group schema).
611
+ #
612
+ resource_class = self.class.scim_resource_type()
613
+
614
+ case attrs_map_or_leaf_value
615
+ when Hash # Nested attribute-value pairs
616
+ attrs_map_or_leaf_value.each do | scim_attribute, sub_attrs_map_or_leaf_value |
617
+ next if scim_attribute&.to_s&.downcase == 'id' && path.empty?
618
+
619
+ sub_scim_hash_or_leaf_value = scim_hash_or_leaf_value&.dig(scim_attribute.to_s)
620
+
621
+ self.from_scim_backend!(
622
+ attrs_map_or_leaf_value: sub_attrs_map_or_leaf_value,
623
+ scim_hash_or_leaf_value: sub_scim_hash_or_leaf_value, # May be 'nil'
624
+ path: path + [scim_attribute]
625
+ )
626
+ end
627
+
628
+ when Array # Static or dynamic maps
629
+ attrs_map_or_leaf_value.each_with_index do | mapped_array_entry |
630
+ next unless mapped_array_entry.is_a?(Hash)
631
+
632
+ if mapped_array_entry.key?(:match) # Static map
633
+ attr_to_match = mapped_array_entry[:match].to_s
634
+ value_to_match = mapped_array_entry[:with]
635
+ sub_attrs_map = mapped_array_entry[:using]
636
+
637
+ # Search for the array entry in the SCIM object that
638
+ # matches the thing we're looking for via :match & :with.
639
+ #
640
+ found_source_list_entry = scim_hash_or_leaf_value&.find do | scim_array_entry |
641
+ scim_array_entry[attr_to_match] == value_to_match
642
+ end
643
+
644
+ self.from_scim_backend!(
645
+ attrs_map_or_leaf_value: sub_attrs_map,
646
+ scim_hash_or_leaf_value: found_source_list_entry, # May be 'nil'
647
+ path: path
648
+ )
649
+
650
+ elsif mapped_array_entry.key?(:list) # Dynamic mapping of each complex list item
651
+ attribute = resource_class.find_attribute(*path)
652
+ method = "#{mapped_array_entry[:list]}="
653
+
654
+ if (attribute&.mutability == 'readWrite' || attribute&.mutability == 'writeOnly') && self.respond_to?(method)
655
+ find_with_proc = mapped_array_entry[:find_with]
656
+
657
+ unless find_with_proc.nil?
658
+ mapped_list = (scim_hash_or_leaf_value || []).map do | source_list_entry |
659
+ find_with_proc.call(source_list_entry)
660
+ end
661
+
662
+ mapped_list.compact!
663
+
664
+ self.public_send(method, mapped_list)
665
+ end
666
+ end
667
+ end # "elsif mapped_array_entry.key?(:list)"
668
+ end # "map_entry&.each do | mapped_array_entry |"
669
+
670
+ when Symbol # Setter/getter method at leaf position in attribute map
671
+ if path == ['externalId'] # Special case held only in schema base class
672
+ mutable = true
673
+ else
674
+ attribute = resource_class.find_attribute(*path)
675
+ mutable = attribute&.mutability == 'readWrite' || attribute&.mutability == 'writeOnly'
676
+ end
677
+
678
+ if mutable
679
+ method = "#{attrs_map_or_leaf_value}="
680
+ self.public_send(method, scim_hash_or_leaf_value) if self.respond_to?(method)
681
+ end
682
+
683
+ # else - fixed value of interest in #to_scim only.
684
+
685
+ end # "case scim_hash_or_leaf_value"
686
+ end # "def from_scim_backend!..."
687
+
688
+ # Recursive back-end for #from_scim_patch! which traverses paths down
689
+ # into one or - if multiple-match filters are encountered - multiple
690
+ # attributes and performs updates on a SCIM Hash representation of
691
+ # 'self'. Throws Scimitar::ErrorResponse (or a subclass thereof) upon
692
+ # encountering any errors.
693
+ #
694
+ # Named parameters:
695
+ #
696
+ # +nature+:: The PATCH operation nature - MUST be a lower case
697
+ # String of 'add', 'remove' or 'replace' ONLY.
698
+ #
699
+ # +path+:: Operation path, as a series of array entries (so
700
+ # an inbound dot-separated path string would first
701
+ # be split into an array by the caller). For
702
+ # internal recursive calls, this will
703
+ #
704
+ # +value+:: The value to apply at the attribute(s) identified
705
+ # by +path+. Ignored for 'remove' operations.
706
+ #
707
+ # +altering_hash+:: The Hash to operate on at the current +path+. For
708
+ # recursive calls, this will be some way down into
709
+ # the SCIM representation of 'self'.
710
+ #
711
+ # Note that SCIM PATCH operations permit *no* path for 'replace' and
712
+ # 'add' operations, meaning "apply to whole object". To avoid special
713
+ # case code in the back-end, callers should in such cases add their
714
+ # own wrapping Hash with a single key addressing the SCIM object of
715
+ # interest and supply this key as the sole array entry in +path+.
716
+ #
717
+ def from_patch_backend!(nature:, path:, value:, altering_hash:)
718
+
719
+ # These all throw exceptions if data is not as expected / required,
720
+ # any of which are rescued below.
721
+ #
722
+ if path.count == 1
723
+ from_patch_backend_apply!(
724
+ nature: nature,
725
+ path: path,
726
+ value: value,
727
+ altering_hash: altering_hash
728
+ )
729
+ else
730
+ from_patch_backend_traverse!(
731
+ nature: nature,
732
+ path: path,
733
+ value: value,
734
+ altering_hash: altering_hash
735
+ )
736
+ end
737
+
738
+ # Treat all exceptions as a malformed or unsupported PATCH.
739
+ #
740
+ rescue => _exception # You can use _exception if debugging
741
+ raise Scimitar::InvalidSyntaxError.new('PATCH describes unrecognised attributes and/or unsupported filters')
742
+ end
743
+
744
+ # Called by #from_patch_backend! when dealing with path elements that
745
+ # is not yet the final (leaf) entry. Deals with filters etc. and
746
+ # traverses down one path level, making one or more recursive calls
747
+ # back up into #from_patch_backend!
748
+ #
749
+ # Parameters are as for #from_patch_backend!, where +path+ is assumed
750
+ # to have at least two entries.
751
+ #
752
+ # Happily throws exceptions if data is not as expected / required.
753
+ #
754
+ def from_patch_backend_traverse!(nature:, path:, value:, altering_hash:)
755
+ path_component, filter = extract_filter_from(path_component: path.first)
756
+
757
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.1
758
+ #
759
+ # o If the target location specifies an attribute that does not exist
760
+ # (has no value), the attribute is added with the new value.
761
+ #
762
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.3
763
+ #
764
+ # o If the target location path specifies an attribute that does not
765
+ # exist, the service provider SHALL treat the operation as an "add".
766
+ #
767
+ # Harmless in this context for 'remove'.
768
+ #
769
+ altering_hash[path_component] ||= {}
770
+
771
+ # Unless the PATCH is bad, inner data is an Array or Hash always as
772
+ # by definition this method is only called at path positions above
773
+ # the leaf (target attribute-to-modify) node.
774
+ #
775
+ inner_data = altering_hash[path_component]
776
+
777
+ found_data_for_recursion = if filter
778
+ matched_hashes = []
779
+
780
+ all_matching_filter(filter: filter, within_array: inner_data) do | matched_hash, _matched_index |
781
+ matched_hashes << matched_hash
782
+ end
783
+
784
+ # Same reason as section 3.5.2.1 / 3.5.2.3 RFC quotes above.
785
+ #
786
+ if nature != 'remove' && matched_hashes.empty?
787
+ new_hash = {}
788
+ altering_hash[path_component] = [new_hash]
789
+ matched_hashes = [new_hash]
790
+ end
791
+
792
+ matched_hashes
793
+ else
794
+ [ inner_data ]
795
+ end
796
+
797
+ found_data_for_recursion.each do | found_data |
798
+ self.from_patch_backend!(
799
+ nature: nature,
800
+ path: path[1..-1],
801
+ value: value,
802
+ altering_hash: found_data
803
+ )
804
+ end
805
+ end
806
+
807
+ # Called by #from_patch_backend! when dealing with path the last path
808
+ # element; applies the operation nature and value. Deals with filters
809
+ # etc. in this final path position (filters only being relevant for
810
+ # 'remove' or 'replace' operations).
811
+ #
812
+ # Parameters are as for #from_patch_backend!, where +path+ is assumed
813
+ # to have exactly one entry only.
814
+ #
815
+ # Happily throws exceptions if data is not as expected / required.
816
+ #
817
+ def from_patch_backend_apply!(nature:, path:, value:, altering_hash:)
818
+ path_component, filter = extract_filter_from(path_component: path.first)
819
+ current_data_at_path = altering_hash[path_component]
820
+
821
+ if current_data_at_path.nil?
822
+ case nature
823
+ when 'add', 'replace'
824
+ if filter.present? # Implies we expected to replace/add to an item matched inside an array
825
+ altering_hash[path_component] = [value]
826
+ else
827
+ altering_hash[path_component] = value
828
+ end
829
+ when 'remove'
830
+ # Nothing to do - no data here anyway
831
+ end
832
+
833
+ # Path filters are not described for 'add' and assumed to have no
834
+ # meaning - https://tools.ietf.org/html/rfc7644#section-3.5.2.1
835
+ #
836
+ elsif filter.present? && nature != 'add'
837
+ compact_after = false
838
+ found_matches = false
839
+
840
+ all_matching_filter(filter: filter, within_array: current_data_at_path) do | matched_hash, matched_index |
841
+ found_matches = true
842
+
843
+ case nature
844
+ when 'remove'
845
+ current_data_at_path[matched_index] = nil
846
+ compact_after = true
847
+ when 'replace'
848
+ matched_hash.reject! { true }
849
+ matched_hash.merge!(value)
850
+ end
851
+ end
852
+
853
+ current_data_at_path.compact! if compact_after
854
+
855
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.1
856
+ #
857
+ # o If the target location specifies an attribute that does not exist
858
+ # (has no value), the attribute is added with the new value.
859
+ #
860
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.3
861
+ #
862
+ # o If the target location path specifies an attribute that does not
863
+ # exist, the service provider SHALL treat the operation as an "add".
864
+ #
865
+ current_data_at_path << value unless found_matches || nature == 'remove'
866
+
867
+ else
868
+ case nature
869
+ when 'add'
870
+ if current_data_at_path.is_a?(Array)
871
+ altering_hash[path_component] += value
872
+ elsif current_data_at_path.is_a?(Hash)
873
+
874
+ # Need to dive down inside a Hash value for additions; a
875
+ # deep merge isn't enough. Take this Group example where
876
+ # nature is "add" and value is:
877
+ #
878
+ # "members":[
879
+ # {
880
+ # "value":"<user-id>"
881
+ # }
882
+ # ]
883
+ #
884
+ # ...in that case, a deep merge would *replace* the array
885
+ # at key 'members' with the above, rather than adding.
886
+ #
887
+ value.keys.each do | key |
888
+ from_patch_backend!(
889
+ nature: nature,
890
+ path: path + [key],
891
+ value: value[key],
892
+ altering_hash: altering_hash
893
+ )
894
+ end
895
+ else
896
+ altering_hash[path_component] = value
897
+ end
898
+ when 'replace'
899
+ altering_hash[path_component] = value
900
+ when 'remove'
901
+ altering_hash.delete(path_component)
902
+ end
903
+ end
904
+ end
905
+
906
+ # Given a path element from SCIM, splits this into the attribute and
907
+ # filter parts. Returns a tuple of [attribute, filter] where +filter+
908
+ # will be +nil+ if no filter string was given.
909
+ #
910
+ # Named parameters:
911
+ #
912
+ # +path_component+:: Path component to examine (a String), e.g.
913
+ # 'userName' or 'emails[type eq "work"]'.
914
+ #
915
+ # Happily throws exceptions if data is not as expected / required.
916
+ #
917
+ def extract_filter_from(path_component:)
918
+ filter = nil
919
+
920
+ if path_component.include?('[')
921
+ composition = path_component.split(/[\[\]]/) # "attribute_name[filter_string]" -> ["attribute_name", "filter_string"]
922
+ path_component = composition.first
923
+ filter = composition.last
924
+ end
925
+
926
+ [path_component, filter]
927
+ end
928
+
929
+ # Given a SCIM filter string and array of Hashes from a SCIM object,
930
+ # search for matches within the array and invoke a given block for
931
+ # each.
932
+ #
933
+ # Obtain filter strings by calling #extract_filter_from.
934
+ #
935
+ # TODO: Support more complex matchers than 'attr eq "value"'.
936
+ #
937
+ # Named parameters:
938
+ #
939
+ # +filter+:: Filter string, e.g. 'type eq "work"'.
940
+ # +within_array+:: Array to search.
941
+ #
942
+ # You must pass a block. It is invoked with each matching array entry
943
+ # (a Hash) and the index into +within_array+ at which this was found.
944
+ #
945
+ # Happily throws exceptions if data is not as expected / required.
946
+ #
947
+ def all_matching_filter(filter:, within_array:, &block)
948
+ filter_components = filter.split(' ')
949
+ raise "Unsupported matcher #{filter.inspect}" unless filter_components.size == 3 && filter_components[1].downcase == 'eq'
950
+
951
+ attribute = filter_components[0]
952
+ value = filter_components[2]
953
+ value = value[1..-2] if value.start_with?('"') && value.end_with?('"')
954
+
955
+ within_array.each.with_index do | hash, index |
956
+ matched = hash.key?(attribute) && hash[attribute]&.to_s == value&.to_s
957
+ yield(hash, index) if matched
958
+ end
959
+ end
960
+
961
+ end # "included do"
962
+ end # "module Mixin"
963
+ end # "module Resources"
964
+ end # "module Scimitar"