powerhome-scimitar 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +708 -0
  4. data/Rakefile +16 -0
  5. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +257 -0
  6. data/app/controllers/scimitar/application_controller.rb +157 -0
  7. data/app/controllers/scimitar/resource_types_controller.rb +28 -0
  8. data/app/controllers/scimitar/resources_controller.rb +203 -0
  9. data/app/controllers/scimitar/schemas_controller.rb +21 -0
  10. data/app/controllers/scimitar/service_provider_configurations_controller.rb +8 -0
  11. data/app/models/scimitar/authentication_error.rb +9 -0
  12. data/app/models/scimitar/authentication_scheme.rb +18 -0
  13. data/app/models/scimitar/bulk.rb +8 -0
  14. data/app/models/scimitar/complex_types/address.rb +12 -0
  15. data/app/models/scimitar/complex_types/base.rb +83 -0
  16. data/app/models/scimitar/complex_types/email.rb +12 -0
  17. data/app/models/scimitar/complex_types/entitlement.rb +12 -0
  18. data/app/models/scimitar/complex_types/ims.rb +12 -0
  19. data/app/models/scimitar/complex_types/name.rb +12 -0
  20. data/app/models/scimitar/complex_types/phone_number.rb +12 -0
  21. data/app/models/scimitar/complex_types/photo.rb +12 -0
  22. data/app/models/scimitar/complex_types/reference_group.rb +12 -0
  23. data/app/models/scimitar/complex_types/reference_member.rb +12 -0
  24. data/app/models/scimitar/complex_types/role.rb +12 -0
  25. data/app/models/scimitar/complex_types/x509_certificate.rb +12 -0
  26. data/app/models/scimitar/engine_configuration.rb +32 -0
  27. data/app/models/scimitar/error_response.rb +32 -0
  28. data/app/models/scimitar/errors.rb +14 -0
  29. data/app/models/scimitar/filter.rb +11 -0
  30. data/app/models/scimitar/filter_error.rb +22 -0
  31. data/app/models/scimitar/invalid_syntax_error.rb +9 -0
  32. data/app/models/scimitar/lists/count.rb +64 -0
  33. data/app/models/scimitar/lists/query_parser.rb +745 -0
  34. data/app/models/scimitar/meta.rb +7 -0
  35. data/app/models/scimitar/not_found_error.rb +10 -0
  36. data/app/models/scimitar/resource_invalid_error.rb +9 -0
  37. data/app/models/scimitar/resource_type.rb +29 -0
  38. data/app/models/scimitar/resources/base.rb +190 -0
  39. data/app/models/scimitar/resources/group.rb +13 -0
  40. data/app/models/scimitar/resources/mixin.rb +1524 -0
  41. data/app/models/scimitar/resources/user.rb +13 -0
  42. data/app/models/scimitar/schema/address.rb +25 -0
  43. data/app/models/scimitar/schema/attribute.rb +132 -0
  44. data/app/models/scimitar/schema/base.rb +90 -0
  45. data/app/models/scimitar/schema/derived_attributes.rb +24 -0
  46. data/app/models/scimitar/schema/email.rb +10 -0
  47. data/app/models/scimitar/schema/entitlement.rb +10 -0
  48. data/app/models/scimitar/schema/group.rb +27 -0
  49. data/app/models/scimitar/schema/ims.rb +10 -0
  50. data/app/models/scimitar/schema/name.rb +20 -0
  51. data/app/models/scimitar/schema/phone_number.rb +10 -0
  52. data/app/models/scimitar/schema/photo.rb +10 -0
  53. data/app/models/scimitar/schema/reference_group.rb +23 -0
  54. data/app/models/scimitar/schema/reference_member.rb +21 -0
  55. data/app/models/scimitar/schema/role.rb +10 -0
  56. data/app/models/scimitar/schema/user.rb +52 -0
  57. data/app/models/scimitar/schema/vdtp.rb +18 -0
  58. data/app/models/scimitar/schema/x509_certificate.rb +22 -0
  59. data/app/models/scimitar/service_provider_configuration.rb +60 -0
  60. data/app/models/scimitar/supportable.rb +14 -0
  61. data/app/views/layouts/scimitar/application.html.erb +14 -0
  62. data/config/initializers/scimitar.rb +111 -0
  63. data/config/routes.rb +6 -0
  64. data/lib/scimitar/engine.rb +63 -0
  65. data/lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb +216 -0
  66. data/lib/scimitar/support/utilities.rb +51 -0
  67. data/lib/scimitar/version.rb +13 -0
  68. data/lib/scimitar.rb +29 -0
  69. data/spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb +25 -0
  70. data/spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb +24 -0
  71. data/spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb +25 -0
  72. data/spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb +30 -0
  73. data/spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb +24 -0
  74. data/spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb +25 -0
  75. data/spec/apps/dummy/app/controllers/mock_groups_controller.rb +13 -0
  76. data/spec/apps/dummy/app/controllers/mock_users_controller.rb +13 -0
  77. data/spec/apps/dummy/app/models/mock_group.rb +83 -0
  78. data/spec/apps/dummy/app/models/mock_user.rb +132 -0
  79. data/spec/apps/dummy/config/application.rb +18 -0
  80. data/spec/apps/dummy/config/boot.rb +2 -0
  81. data/spec/apps/dummy/config/environment.rb +2 -0
  82. data/spec/apps/dummy/config/environments/test.rb +38 -0
  83. data/spec/apps/dummy/config/initializers/cookies_serializer.rb +3 -0
  84. data/spec/apps/dummy/config/initializers/scimitar.rb +61 -0
  85. data/spec/apps/dummy/config/initializers/session_store.rb +3 -0
  86. data/spec/apps/dummy/config/routes.rb +45 -0
  87. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +24 -0
  88. data/spec/apps/dummy/db/migrate/20210308020313_create_mock_groups.rb +10 -0
  89. data/spec/apps/dummy/db/migrate/20210308044214_create_join_table_mock_groups_mock_users.rb +13 -0
  90. data/spec/apps/dummy/db/schema.rb +48 -0
  91. data/spec/controllers/scimitar/application_controller_spec.rb +296 -0
  92. data/spec/controllers/scimitar/resource_types_controller_spec.rb +94 -0
  93. data/spec/controllers/scimitar/resources_controller_spec.rb +247 -0
  94. data/spec/controllers/scimitar/schemas_controller_spec.rb +83 -0
  95. data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +22 -0
  96. data/spec/models/scimitar/complex_types/address_spec.rb +18 -0
  97. data/spec/models/scimitar/complex_types/email_spec.rb +21 -0
  98. data/spec/models/scimitar/lists/count_spec.rb +147 -0
  99. data/spec/models/scimitar/lists/query_parser_spec.rb +830 -0
  100. data/spec/models/scimitar/resource_type_spec.rb +21 -0
  101. data/spec/models/scimitar/resources/base_spec.rb +485 -0
  102. data/spec/models/scimitar/resources/base_validation_spec.rb +86 -0
  103. data/spec/models/scimitar/resources/mixin_spec.rb +3562 -0
  104. data/spec/models/scimitar/resources/user_spec.rb +68 -0
  105. data/spec/models/scimitar/schema/attribute_spec.rb +99 -0
  106. data/spec/models/scimitar/schema/base_spec.rb +64 -0
  107. data/spec/models/scimitar/schema/group_spec.rb +87 -0
  108. data/spec/models/scimitar/schema/user_spec.rb +720 -0
  109. data/spec/requests/active_record_backed_resources_controller_spec.rb +1354 -0
  110. data/spec/requests/application_controller_spec.rb +61 -0
  111. data/spec/requests/controller_configuration_spec.rb +17 -0
  112. data/spec/requests/engine_spec.rb +45 -0
  113. data/spec/spec_helper.rb +101 -0
  114. data/spec/spec_helper_spec.rb +30 -0
  115. data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +169 -0
  116. metadata +321 -0
@@ -0,0 +1,1524 @@
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 of queryable SCIM attribute options, described
224
+ # below - for example:
225
+ #
226
+ # def self.scim_queryable_attributes
227
+ # return {
228
+ # 'name.givenName' => { column: :first_name },
229
+ # 'name.familyName' => { column: :last_name },
230
+ # 'emails' => { columns: [ :work_email_address, :home_email_address ] },
231
+ # 'emails.value' => { columns: [ :work_email_address, :home_email_address ] },
232
+ # 'emails.type' => { ignore: true },
233
+ # 'groups.value' => { column: Group.arel_table[:id] }
234
+ # }
235
+ # end
236
+ #
237
+ # Column references can be either a Symbol representing a column within
238
+ # the resource model table, or an <tt>Arel::Attribute</tt> instance via
239
+ # e.g. <tt>MyModel.arel_table[:my_column]</tt>.
240
+ #
241
+ # === Queryable SCIM attribute options
242
+ #
243
+ # +:column+:: Just one simple column for a mapping.
244
+ #
245
+ # +:columns+:: An Array of columns that you want to map using 'OR' for a
246
+ # single search of the corresponding entity.
247
+ #
248
+ # +:ignore+:: When set to +true+, the matching attribute is ignored rather
249
+ # than resulting in an "invalid filter" exception. Beware
250
+ # possibilities for surprised clients getting a broader result
251
+ # set than expected, since a constraint may have been ignored.
252
+ #
253
+ # Filtering is currently limited and searching within e.g. arrays of data
254
+ # is not supported; only simple top-level keys can be mapped.
255
+ #
256
+ #
257
+ # == Optional methods
258
+ #
259
+ # === scim_timestamps_map
260
+ #
261
+ # If you implement this class method, it should return a Hash with one or
262
+ # both of the keys 'created' and 'lastModified', as Symbols. The values
263
+ # should be methods that the including method supports which return a
264
+ # creation or most-recently-updated time, respectively. The returned object
265
+ # mustsupport #iso8601 to convert to a String representation. Example for a
266
+ # typical ActiveRecord object with standard timestamps:
267
+ #
268
+ # def self.scim_timestamps_map
269
+ # {
270
+ # created: :created_at,
271
+ # lastModified: :updated_at
272
+ # }
273
+ # end
274
+ #
275
+ module Mixin
276
+ extend ActiveSupport::Concern
277
+
278
+ included do
279
+ %w{
280
+ scim_resource_type
281
+ scim_attributes_map
282
+ scim_mutable_attributes
283
+ scim_queryable_attributes
284
+ }.each do | required_class_method_name |
285
+ raise "You must define ::#{required_class_method_name} in #{self}" unless self.respond_to?(required_class_method_name)
286
+ end
287
+
288
+ # An instance-level method which calls ::scim_mutable_attributes and
289
+ # either uses its returned array of mutable attribute names or reads
290
+ # ::scim_attributes_map and determines the list from that. Caches
291
+ # the result in an instance variable.
292
+ #
293
+ def scim_mutable_attributes
294
+ @scim_mutable_attributes ||= self.class.scim_mutable_attributes()
295
+
296
+ if @scim_mutable_attributes.nil?
297
+ @scim_mutable_attributes = Set.new
298
+
299
+ # Variant of https://stackoverflow.com/a/49315255
300
+ #
301
+ extractor = ->(outer_enum) do
302
+ outer_enum.each do |key, value|
303
+ enum = [key, value].detect(&Enumerable.method(:===))
304
+ if enum.nil?
305
+ @scim_mutable_attributes << value if value.is_a?(Symbol) && self.respond_to?("#{value}=")
306
+ else
307
+ if enum.is_a?(Hash)
308
+ extractor.call(enum)
309
+ elsif enum.is_a?(Array)
310
+ enum.each do | static_or_dynamic_mapping |
311
+ if static_or_dynamic_mapping.key?(:match) # Static
312
+ extractor.call(static_or_dynamic_mapping[:using])
313
+ elsif static_or_dynamic_mapping.key?(:find_with) # Dynamic
314
+ @scim_mutable_attributes << static_or_dynamic_mapping[:list]
315
+ end
316
+ end
317
+ end
318
+ end
319
+ end
320
+ end
321
+
322
+ extractor.call(self.class.scim_attributes_map())
323
+ @scim_mutable_attributes.delete(:id)
324
+ end
325
+
326
+ @scim_mutable_attributes
327
+ end
328
+
329
+ # An instance level method which calls ::scim_queryable_attributes and
330
+ # caches the result in an instance variable, for symmetry with
331
+ # #scim_mutable_attributes and to permit potential future enhancements
332
+ # for how the return value of ::scim_queryable_attributes is handled.
333
+ #
334
+ def scim_queryable_attributes
335
+ @scim_queryable_attributes ||= self.class.scim_queryable_attributes()
336
+ end
337
+
338
+ # Render self as a SCIM object using ::scim_attributes_map. Fields that
339
+ # are marked as <tt>returned: 'never'</tt> are excluded.
340
+ #
341
+ # +location+:: The location (HTTP(S) full URI) of this resource,
342
+ # in the domain of the object including this mixin -
343
+ # "your" IDs, not the remote SCIM client's external
344
+ # IDs. #url_for is a good way to generate this.
345
+ #
346
+ # +include_attributes+:: The attributes that should be included in the
347
+ # response, in the form of a list of full attribute
348
+ # paths. See RFC 7644 section 3.9 and section 3.10.
349
+ # An empty collection will include all attributes.
350
+ #
351
+ def to_scim(location:, include_attributes: [])
352
+ map = self.class.scim_attributes_map()
353
+ resource_type = self.class.scim_resource_type()
354
+ timestamps_map = self.class.scim_timestamps_map() if self.class.respond_to?(:scim_timestamps_map)
355
+ attrs_hash = self.to_scim_backend(data_source: self, resource_type: resource_type, attrs_map_or_leaf_value: map, include_attributes: include_attributes)
356
+ resource = resource_type.new(attrs_hash)
357
+ meta_attrs_hash = { location: location }
358
+
359
+ meta_attrs_hash[:created ] = self.send(timestamps_map[:created ])&.iso8601(0) if timestamps_map&.key?(:created)
360
+ meta_attrs_hash[:lastModified] = self.send(timestamps_map[:lastModified])&.iso8601(0) if timestamps_map&.key?(:lastModified)
361
+
362
+ resource.meta = Meta.new(meta_attrs_hash)
363
+ return resource
364
+ end
365
+
366
+ # Update self from a SCIM object using ::scim_attributes_map. This does
367
+ # NOT PERSIST ("save") 'this' instance - it just sets attribute values
368
+ # within it.
369
+ #
370
+ # If you are mixing into an ActiveRecord subclass then depending on how
371
+ # your ::scim_attributes_map updates associated objects (if any), Rails
372
+ # might make database writes to update those associations immediately.
373
+ # Given this, it is highly recommended that you wrap calls to this
374
+ # method and your subsequent save of 'self' inside a transaction.
375
+ #
376
+ # ActiveRecord::Base.transaction do
377
+ # record.from_scim!(scim_hash: some_payload)
378
+ # record.save!
379
+ # end
380
+ #
381
+ # Call ONLY for POST or PUT. For PATCH, see #from_scim_patch!.
382
+ #
383
+ # Mandatory named parameters:
384
+ #
385
+ # +scim_hash+:: A Hash that's the result of parsing a JSON payload
386
+ # from an inbound POST or PUT request.
387
+ #
388
+ # Optional named parameters:
389
+ #
390
+ # +with_clearing+:: According to RFC 7644 section 3.5.1, PUT operations
391
+ # MAY default or clear any attribute missing from
392
+ # +scim_hash+ as this is deemed "not asserted by the
393
+ # client" (see
394
+ # https://tools.ietf.org/html/rfc7644#section-3.5.1).
395
+ # This parameter controls such behaviour. It defaults
396
+ # to +true+, so clearing is applied - single value
397
+ # attributes are set to +nil+ and arrays are emptied.
398
+ # If +false+, an unusual <b>preservation</b> mode is
399
+ # applied and anything absent from +scim_hash+ will
400
+ # have no impact on the target object (any mapped
401
+ # attributes in the local data model with existing
402
+ # non-nil values will retain those values).
403
+ #
404
+ # Returns 'self', for convenience of e.g. chaining other methods.
405
+ #
406
+ def from_scim!(scim_hash:, with_clearing: true)
407
+ scim_hash.freeze()
408
+ map = self.class.scim_attributes_map().freeze()
409
+
410
+ self.from_scim_backend!(
411
+ attrs_map_or_leaf_value: map,
412
+ scim_hash_or_leaf_value: scim_hash,
413
+ with_clearing: with_clearing
414
+ )
415
+
416
+ return self
417
+ end
418
+
419
+ # Update self from a SCIM object representing a PATCH operation. This
420
+ # does NOT PERSIST ("save") 'this' instance - it just sets attribute
421
+ # values within it.
422
+ #
423
+ # SCIM patch operations are complex. A series of operations is given,
424
+ # each asking to add, remove or replace specific attributes or, via
425
+ # filters, potentially multiple attributes if the filter matches many.
426
+ #
427
+ # Pass the PATCH payload. Then:
428
+ #
429
+ # * This instance (self) is converted to a SCIM representation via
430
+ # calling #to_scim.
431
+ #
432
+ # * The inbound operations are applied. A Scimitar::ErrorResponse may
433
+ # be thrown if the patch data looks bad - if you are calling from a
434
+ # Scimitar::ActiveRecordBackedResourcesController subclass, this will
435
+ # be handled for you and returned as an appropriate HTTP response.
436
+ # Otherwise, you'll need to rescue it yourself and e.g. make use of
437
+ # Scimitar::ApplicationController#handle_scim_error, passing the
438
+ # exception object to it, if you are a subclass of that base class.
439
+ #
440
+ # * The (possibly) updated SCIM representation of 'self' is pushed
441
+ # back into 'this' instance via #from_scim!.
442
+ #
443
+ # IMPORTANT: Please see #from_scim! for notes about associations and
444
+ # use of transactions with ActiveRecord.
445
+ #
446
+ # Call ONLY for PATCH. For POST and PUT, see #from_scim!.
447
+ #
448
+ def from_scim_patch!(patch_hash:)
449
+ frozen_ci_patch_hash = patch_hash.with_indifferent_case_insensitive_access().freeze()
450
+ ci_scim_hash = self.to_scim(location: '(unused)').as_json().with_indifferent_case_insensitive_access()
451
+ operations = frozen_ci_patch_hash['operations']
452
+
453
+ raise Scimitar::InvalidSyntaxError.new("Missing PATCH \"operations\"") unless operations
454
+
455
+ operations.each do |operation|
456
+ nature = operation['op' ]&.downcase
457
+ path_str = operation['path' ]
458
+ value = operation['value']
459
+
460
+ unless ['add', 'remove', 'replace'].include?(nature)
461
+ raise Scimitar::InvalidSyntaxError.new("Unrecognised PATCH \"op\" value of \"#{nature}\"")
462
+ end
463
+
464
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.2
465
+ #
466
+ # o If "path" is unspecified, the operation fails with HTTP status
467
+ # code 400 and a "scimType" error code of "noTarget".
468
+ #
469
+ # (...for "add" or "replace", no path means "whole object").
470
+ #
471
+ if nature == 'remove' && path_str.blank?
472
+ raise Scimitar::ErrorResponse.new(
473
+ status: 400,
474
+ scimType: 'noTarget',
475
+ detail: 'No "path" target given for "replace" operation'
476
+ )
477
+ end
478
+
479
+ # Deal with the exception case of no path, where the entire object
480
+ # is addressed. It's easier internally to treat a path as a set of
481
+ # steps towards a final Hash key (attribute) with an associated
482
+ # value to change (and filters may apply if the value is an Array).
483
+ #
484
+ extract_root = false
485
+ if path_str.blank?
486
+ extract_root = true
487
+ path_str = 'root'
488
+ ci_scim_hash = { 'root' => ci_scim_hash }.with_indifferent_case_insensitive_access()
489
+ end
490
+
491
+ # Handle extension schema. Contributed by @bettysteger and
492
+ # @MorrisFreeman via:
493
+ #
494
+ # https://github.com/RIPAGlobal/scimitar/issues/48
495
+ # https://github.com/RIPAGlobal/scimitar/pull/49
496
+ #
497
+ # Note the ":" separating the schema ID (URN) from the attribute.
498
+ # The nature of JSON rendering / other payloads might lead you to
499
+ # expect a "." as with any complex types, but that's not the case;
500
+ # see https://tools.ietf.org/html/rfc7644#section-3.10, or
501
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2 of which in
502
+ # particular, https://tools.ietf.org/html/rfc7644#page-35.
503
+ #
504
+ paths = []
505
+ self.class.scim_resource_type.extended_schemas.each do |schema|
506
+ path_str.downcase.split(schema.id.downcase + ':').drop(1).each do |path|
507
+ paths += [schema.id] + path.split('.')
508
+ end
509
+ end
510
+ paths = path_str.split('.') if paths.empty?
511
+
512
+ self.from_patch_backend!(
513
+ nature: nature,
514
+ path: paths,
515
+ value: value,
516
+ altering_hash: ci_scim_hash,
517
+ with_attr_map: self.class.scim_attributes_map()
518
+ )
519
+
520
+ if extract_root
521
+ ci_scim_hash = ci_scim_hash['root']
522
+ end
523
+ end
524
+
525
+ self.from_scim!(scim_hash: ci_scim_hash, with_clearing: false)
526
+ return self
527
+ end
528
+
529
+ private # (...but note that we're inside "included do" within a mixin)
530
+
531
+ # A recursive method that takes a Hash mapping SCIM attributes to the
532
+ # mixing in class's attributes and via ::scim_attributes_map replaces
533
+ # symbols in the schema with the corresponding value from the user.
534
+ #
535
+ # Given a schema with symbols, this method will search through the
536
+ # object for the symbols, send those symbols to the model and replace
537
+ # the symbol with the return value.
538
+ #
539
+ # +data_source+:: The source of data. At the top level,
540
+ # this is "self" (an instance of the
541
+ # class mixing in this module).
542
+ #
543
+ # +resource_type+:: The resource type carrying the schemas
544
+ # describing the SCIM object. If at the
545
+ # top level when +data_source+ is +self+,
546
+ # this would be sent as
547
+ # <tt>self.class.scim_resource_type()</tt>.
548
+ #
549
+ # +attrs_map_or_leaf_value+:: The attribute map. At the top level,
550
+ # this is from ::scim_attributes_map.
551
+ #
552
+ # +include_attributes+:: The attributes that should be included
553
+ # in the response, in the form of a list of
554
+ # full attribute paths. See RFC 7644 section
555
+ # 3.9 and section 3.10.
556
+ # An empty collection will include all attributes.
557
+ #
558
+ # Internal recursive calls also send:
559
+ #
560
+ # +attribute_path+:: Array of path components to the
561
+ # attribute, which can be found through
562
+ # +resource_type+ so that things like the
563
+ # "+returned+" state can be checked.
564
+ #
565
+ def to_scim_backend(
566
+ data_source:,
567
+ resource_type:,
568
+ attrs_map_or_leaf_value:,
569
+ include_attributes:,
570
+ attribute_path: []
571
+ )
572
+ return unless attribute_included?(include_attributes: include_attributes,
573
+ attribute_path: attribute_path)
574
+
575
+ # On assumption of a top-level attributes list, the 'return never'
576
+ # state is only checked on the recursive call from a Hash type. The
577
+ # other handled types are assumed to only happen when called
578
+ # recursively, so no need to check as no such call is made for a
579
+ # 'return never' attribute.
580
+ #
581
+ case attrs_map_or_leaf_value
582
+ when Hash # Expected at top-level of any map, or nested within
583
+ attrs_map_or_leaf_value.each.with_object({}) do |(key, value), hash|
584
+ nested_attribute_path = attribute_path + [key]
585
+
586
+ if resource_type.find_attribute(*nested_attribute_path)&.returned != "never"
587
+ hash[key] = to_scim_backend(
588
+ data_source: data_source,
589
+ resource_type: resource_type,
590
+ attribute_path: nested_attribute_path,
591
+ attrs_map_or_leaf_value: value,
592
+ include_attributes: include_attributes
593
+ )
594
+ end
595
+ end.compact
596
+
597
+ when Array # Static or dynamic mapping against lists in data source
598
+ built_dynamic_list = false
599
+ mapped_array = attrs_map_or_leaf_value.map do |value|
600
+ if ! value.is_a?(Hash)
601
+ raise 'Bad attribute map: Array contains someting other than mapping Hash(es)'
602
+
603
+ elsif value.key?(:match) # Static map
604
+ static_hash = { value[:match] => value[:with] }
605
+ static_hash.merge!(
606
+ to_scim_backend(
607
+ data_source: data_source,
608
+ resource_type: resource_type,
609
+ attribute_path: attribute_path,
610
+ attrs_map_or_leaf_value: value[:using],
611
+ include_attributes: include_attributes
612
+ )
613
+ )
614
+ static_hash
615
+
616
+ elsif value.key?(:list) # Dynamic mapping of each complex list item
617
+ built_dynamic_list = true
618
+ list = data_source.public_send(value[:list])
619
+ list.map do |list_entry|
620
+ to_scim_backend(
621
+ data_source: list_entry,
622
+ resource_type: resource_type,
623
+ attribute_path: attribute_path,
624
+ attrs_map_or_leaf_value: value[:using],
625
+ include_attributes: include_attributes
626
+ )
627
+ end
628
+
629
+ else # Unknown type, just treat as flat values
630
+ raise 'Bad attribute map: Mapping Hash inside Array does not contain supported data'
631
+
632
+ end
633
+ end
634
+
635
+ # If a dynamic list was generated, it's sitting as a nested
636
+ # Array in the first index of the mapped result; pull it out.
637
+ #
638
+ mapped_array = mapped_array.first if built_dynamic_list
639
+ mapped_array
640
+
641
+ when Symbol # Leaf node, Symbol -> reader method to call on data source
642
+ if data_source.respond_to?(attrs_map_or_leaf_value) # A read-accessor exists?
643
+ value = data_source.public_send(attrs_map_or_leaf_value)
644
+ value = value.to_s if value.is_a?(Numeric)
645
+ value
646
+ else
647
+ nil
648
+ end
649
+
650
+ else # Leaf node, other type -> literal static value to use
651
+ attrs_map_or_leaf_value
652
+ end
653
+ end
654
+
655
+ # Given a SCIM resource representation (left) and an attribute map to
656
+ # an instance of the mixin-including class / 'self' (right), walk the
657
+ # attribute map, looking up equivalent values in the SCIM resource.
658
+ # Mutable attributes will be set from the SCIM data, or cleared if
659
+ # the SCIM data has nothing set ("PUT" semantics; splat resource data
660
+ # in full, writing all mapped attributes).
661
+ #
662
+ # * Literal map values like 'true' are for read-time uses; ignored.
663
+ # * Symbol map values are treated as read accessor method names and a
664
+ # write accessor checked for by adding "=". If this method exists,
665
+ # a value write is attempted using the SCIM resource data.
666
+ # * Static and dynamic array mappings perform as documented for
667
+ # ::scim_attributes_map.
668
+ #
669
+ # { | {
670
+ # "userName": "foo", | 'id': :id,
671
+ # "name": { | 'externalId': :scim_uid,
672
+ # "givenName": "Foo", | 'userName': :username,
673
+ # "familyName": "Bar" | 'name': {
674
+ # }, | 'givenName': :first_name,
675
+ # "active": true, | 'familyName': :last_name
676
+ # "emails": [ | },
677
+ # { | 'emails': [
678
+ # "type": "work", <------\ | {
679
+ # "primary": true, \------+--- 'match': 'type',
680
+ # "value": "foo.bar@test.com" | 'with': 'work',
681
+ # } | 'using': {
682
+ # ], | 'value': :work_email_address,
683
+ # "phoneNumbers": [ | 'primary': true
684
+ # { | }
685
+ # "type": "work", | }
686
+ # "primary": false, | ],
687
+ # "value": "+642201234567" | groups: [
688
+ # } | {
689
+ # ], | list: :groups,
690
+ # "id": "42", | using: {
691
+ # "externalId": "AA02984", | value: :id,
692
+ # "meta": { | display: :full_name
693
+ # "location": "https://test.com/mock_users/42", | }
694
+ # "resourceType": "User" | }
695
+ # }, | ],
696
+ # "schemas": [ | 'active': :is_active
697
+ # "urn:ietf:params:scim:schemas:core:2.0:User" | }
698
+ # ] |
699
+ # } |
700
+ #
701
+ # Named parameters:
702
+ #
703
+ # +attrs_map_or_leaf_value+:: Attribute map; recursive calls just
704
+ # pass in the fragment for recursion, so
705
+ # at the deepest level, this ends up
706
+ # being a leaf node which may have a
707
+ # Symbol method name, used to look for a
708
+ # write accessor; or a read-only literal,
709
+ # which is ignored (right hand side of
710
+ # the ASCII art diagram).
711
+ #
712
+ # +scim_hash_or_leaf_value+:: Similar to +attrs_map_or_leaf_value+
713
+ # but tracks the SCIM schema data being
714
+ # read as input source material (left
715
+ # hand side of the ASCII art diagram).
716
+ #
717
+ # +with_clearing+:: If +true+, attributes absent in
718
+ # +scim_hash_or_leaf_value+ but present
719
+ # in +attrs_map_or_leaf_value+ will be
720
+ # cleared (+nil+ or empty array), for PUT
721
+ # ("replace") semantics. If +false+, such
722
+ # missing attribute values are left
723
+ # untouched - whatever mapped value is in
724
+ # +self+ is preserved.
725
+ #
726
+ # +path+:: Array of SCIM attribute names giving a
727
+ # path into the SCIM schema where
728
+ # iteration has reached. Used to find the
729
+ # schema attribute definiton and check
730
+ # mutability before writing.
731
+ #
732
+ def from_scim_backend!(
733
+ attrs_map_or_leaf_value:,
734
+ scim_hash_or_leaf_value:,
735
+ with_clearing:,
736
+ path: []
737
+ )
738
+ scim_hash_or_leaf_value = scim_hash_or_leaf_value.with_indifferent_case_insensitive_access() if scim_hash_or_leaf_value.is_a?(Hash)
739
+
740
+ # We get the schema via this instance's class's resource type, even
741
+ # if we end up in collections of other types - because it's *this*
742
+ # schema at the top level that defines the attributes of interest
743
+ # within any collections, not SCIM schema - if any - for the items
744
+ # within the collection (a User's "groups" per-array-entry schema
745
+ # is quite different from the Group schema).
746
+ #
747
+ resource_class = self.class.scim_resource_type()
748
+
749
+ case attrs_map_or_leaf_value
750
+ when Hash # Nested attribute-value pairs
751
+ attrs_map_or_leaf_value.each do | scim_attribute, sub_attrs_map_or_leaf_value |
752
+ next if scim_attribute&.to_s&.downcase == 'id' && path.empty?
753
+
754
+ # Handle extension schema. Contributed by @bettysteger and
755
+ # @MorrisFreeman via:
756
+ #
757
+ # https://github.com/RIPAGlobal/scimitar/issues/48
758
+ # https://github.com/RIPAGlobal/scimitar/pull/49
759
+ #
760
+ attribute_tree = []
761
+ resource_class.extended_schemas.each do |schema|
762
+ attribute_tree << schema.id and break if schema.scim_attributes.any? { |attribute| attribute.name == scim_attribute.to_s }
763
+ end
764
+ attribute_tree << scim_attribute.to_s
765
+
766
+ continue_processing = if with_clearing
767
+ true
768
+ else
769
+ most_of_attribute_tree = attribute_tree[...-1]
770
+ last_attribute_in_tree = attribute_tree.last
771
+
772
+ if most_of_attribute_tree.empty?
773
+ scim_hash_or_leaf_value&.key?(last_attribute_in_tree)
774
+ else
775
+ scim_hash_or_leaf_value&.dig(*most_of_attribute_tree)&.key?(last_attribute_in_tree)
776
+ end
777
+ end
778
+
779
+ if continue_processing
780
+ sub_scim_hash_or_leaf_value = scim_hash_or_leaf_value&.dig(*attribute_tree)
781
+
782
+ self.from_scim_backend!(
783
+ attrs_map_or_leaf_value: sub_attrs_map_or_leaf_value,
784
+ scim_hash_or_leaf_value: sub_scim_hash_or_leaf_value, # May be 'nil'
785
+ with_clearing: with_clearing,
786
+ path: path + [scim_attribute]
787
+ )
788
+ end
789
+ end
790
+
791
+ when Array # Static or dynamic maps
792
+ attrs_map_or_leaf_value.each_with_index do | mapped_array_entry |
793
+ next unless mapped_array_entry.is_a?(Hash)
794
+
795
+ if mapped_array_entry.key?(:match) # Static map
796
+ attr_to_match = mapped_array_entry[:match].to_s
797
+ value_to_match = mapped_array_entry[:with]
798
+ sub_attrs_map = mapped_array_entry[:using]
799
+
800
+ # Search for the array entry in the SCIM object that
801
+ # matches the thing we're looking for via :match & :with.
802
+ #
803
+ found_source_list_entry = scim_hash_or_leaf_value&.find do | scim_array_entry |
804
+ scim_array_entry[attr_to_match] == value_to_match
805
+ end
806
+
807
+ self.from_scim_backend!(
808
+ attrs_map_or_leaf_value: sub_attrs_map,
809
+ scim_hash_or_leaf_value: found_source_list_entry, # May be 'nil'
810
+ with_clearing: with_clearing,
811
+ path: path
812
+ )
813
+
814
+ elsif mapped_array_entry.key?(:list) # Dynamic mapping of each complex list item
815
+ attribute = resource_class.find_attribute(*path)
816
+ method = "#{mapped_array_entry[:list]}="
817
+
818
+ if (attribute&.mutability == 'readWrite' || attribute&.mutability == 'writeOnly') && self.respond_to?(method)
819
+ find_with_proc = mapped_array_entry[:find_with]
820
+
821
+ unless find_with_proc.nil?
822
+ mapped_list = (scim_hash_or_leaf_value || []).map do | source_list_entry |
823
+ find_with_proc.call(source_list_entry)
824
+ end
825
+
826
+ mapped_list.compact!
827
+
828
+ self.public_send(method, mapped_list)
829
+ end
830
+ end
831
+ end # "elsif mapped_array_entry.key?(:list)"
832
+ end # "map_entry&.each do | mapped_array_entry |"
833
+
834
+ when Symbol # Setter/getter method at leaf position in attribute map
835
+ if path.length == 1 && path.first&.to_s&.downcase == 'externalid' # Special case held only in schema base class
836
+ mutable = true
837
+ else
838
+ attribute = resource_class.find_attribute(*path)
839
+ mutable = attribute&.mutability == 'readWrite' || attribute&.mutability == 'writeOnly'
840
+ end
841
+
842
+ if mutable
843
+ method = "#{attrs_map_or_leaf_value}="
844
+ self.public_send(method, scim_hash_or_leaf_value) if self.respond_to?(method)
845
+ end
846
+
847
+ # else - fixed value of interest in #to_scim only.
848
+
849
+ end # "case scim_hash_or_leaf_value"
850
+ end # "def from_scim_backend!..."
851
+
852
+ # Recursive back-end for #from_scim_patch! which traverses paths down
853
+ # into one or - if multiple-match filters are encountered - multiple
854
+ # attributes and performs updates on a SCIM Hash representation of
855
+ # 'self'. Throws Scimitar::ErrorResponse (or a subclass thereof) upon
856
+ # encountering any errors.
857
+ #
858
+ # Named parameters:
859
+ #
860
+ # +nature+:: The PATCH operation nature - MUST be a lower case
861
+ # String of 'add', 'remove' or 'replace' ONLY.
862
+ #
863
+ # +path+:: Operation path, as a series of array entries (so
864
+ # an inbound dot-separated path string would first
865
+ # be split into an array by the caller). For
866
+ # internal recursive calls, this will be a subset
867
+ # of array entries from an index somewhere into the
868
+ # top-level array, through to its end.
869
+ #
870
+ # +value+:: The value to apply at the attribute(s) identified
871
+ # by +path+. Ignored for 'remove' operations.
872
+ #
873
+ # +altering_hash+:: The Hash to operate on at the current +path+. For
874
+ # recursive calls, this will be some way down into
875
+ # the SCIM representation of 'self'. MUST be a
876
+ # HashWithIndifferentCaseInsensitiveAccess.
877
+ #
878
+ # Note that SCIM PATCH operations permit *no* path for 'replace' and
879
+ # 'add' operations, meaning "apply to whole object". To avoid special
880
+ # case code in the back-end, callers should in such cases add their
881
+ # own wrapping Hash with a single key addressing the SCIM object of
882
+ # interest and supply this key as the sole array entry in +path+.
883
+ #
884
+ def from_patch_backend!(nature:, path:, value:, altering_hash:, with_attr_map:)
885
+ raise 'Case sensitivity violation' unless altering_hash.is_a?(Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess)
886
+
887
+ # These all throw exceptions if data is not as expected / required,
888
+ # any of which are rescued below.
889
+ #
890
+ if path.count == 1
891
+ from_patch_backend_apply!(
892
+ nature: nature,
893
+ path: path,
894
+ value: value,
895
+ altering_hash: altering_hash,
896
+ with_attr_map: with_attr_map
897
+ )
898
+ else
899
+ from_patch_backend_traverse!(
900
+ nature: nature,
901
+ path: path,
902
+ value: value,
903
+ altering_hash: altering_hash,
904
+ with_attr_map: with_attr_map
905
+ )
906
+ end
907
+
908
+ # Treat all exceptions as a malformed or unsupported PATCH.
909
+ #
910
+ rescue => _exception # You can use _exception if debugging
911
+ raise Scimitar::InvalidSyntaxError.new('PATCH describes unrecognised attributes and/or unsupported filters')
912
+ end
913
+
914
+ # Called by #from_patch_backend! when dealing with path elements that
915
+ # is not yet the final (leaf) entry. Deals with filters etc. and
916
+ # traverses down one path level, making one or more recursive calls
917
+ # back up into #from_patch_backend!
918
+ #
919
+ # Parameters are as for #from_patch_backend!, where +path+ is assumed
920
+ # to have at least two entries.
921
+ #
922
+ # Happily throws exceptions if data is not as expected / required.
923
+ #
924
+ def from_patch_backend_traverse!(nature:, path:, value:, altering_hash:, with_attr_map:)
925
+ raise 'Case sensitivity violation' unless altering_hash.is_a?(Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess)
926
+
927
+ path_component, filter = extract_filter_from(path_component: path.first)
928
+
929
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.1
930
+ #
931
+ # o If the target location specifies an attribute that does not exist
932
+ # (has no value), the attribute is added with the new value.
933
+ #
934
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.3
935
+ #
936
+ # o If the target location path specifies an attribute that does not
937
+ # exist, the service provider SHALL treat the operation as an "add".
938
+ #
939
+ # Harmless in this context for 'remove'.
940
+ #
941
+ altering_hash[path_component] ||= Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess.new
942
+
943
+ # Unless the PATCH is bad, inner data is an Array or Hash always as
944
+ # by definition this method is only called at path positions above
945
+ # the leaf (target attribute-to-modify) node.
946
+ #
947
+ inner_data = altering_hash[path_component]
948
+
949
+ found_data_for_recursion = if filter
950
+ matched_hashes = []
951
+
952
+ all_matching_filter(filter: filter, within_array: inner_data) do | matched_hash, _matched_index |
953
+ matched_hashes << matched_hash
954
+ end
955
+
956
+ # Same reason as section 3.5.2.1 / 3.5.2.3 RFC quotes above.
957
+ #
958
+ if nature != 'remove' && matched_hashes.empty?
959
+ new_hash = Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess.new
960
+ altering_hash[path_component] = [new_hash]
961
+ matched_hashes = [new_hash]
962
+ end
963
+
964
+ matched_hashes
965
+ else
966
+ [ inner_data ]
967
+ end
968
+
969
+ found_data_for_recursion.each do | found_data |
970
+ attr_map = with_attr_map[path_component.to_sym]
971
+
972
+ # Static array mappings need us to find the right map entry that
973
+ # corresponds to the SCIM data at hand and recurse back into the
974
+ # patch engine with the ":using" attribute map data.
975
+ #
976
+ if attr_map.is_a?(Array)
977
+ array_attr_map = find_matching_static_attr_map(data: found_data, with_attr_map: attr_map)
978
+ attr_map = array_attr_map unless array_attr_map.nil?
979
+ end
980
+
981
+ self.from_patch_backend!(
982
+ nature: nature,
983
+ path: path[1..],
984
+ value: value,
985
+ altering_hash: found_data,
986
+ with_attr_map: attr_map
987
+ )
988
+ end
989
+ end
990
+
991
+ # Called by #from_patch_backend! when dealing with path the last path
992
+ # element; applies the operation nature and value. Deals with filters
993
+ # etc. in this final path position (filters only being relevant for
994
+ # 'remove' or 'replace' operations).
995
+ #
996
+ # Parameters are as for #from_patch_backend!, where +path+ is assumed
997
+ # to have exactly one entry only.
998
+ #
999
+ # Happily throws exceptions if data is not as expected / required.
1000
+ #
1001
+ def from_patch_backend_apply!(nature:, path:, value:, altering_hash:, with_attr_map:)
1002
+ raise 'Case sensitivity violation' unless altering_hash.is_a?(Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess)
1003
+
1004
+ path_component, filter = extract_filter_from(path_component: path.first)
1005
+ current_data_at_path = altering_hash[path_component]
1006
+
1007
+ if current_data_at_path.nil?
1008
+ case nature
1009
+ when 'add', 'replace'
1010
+ if filter.present? # Implies we expected to replace/add to an item matched inside an array
1011
+ altering_hash[path_component] = [value]
1012
+ else
1013
+ altering_hash[path_component] = value
1014
+ end
1015
+ when 'remove'
1016
+ # Nothing to do - no data here anyway
1017
+ end
1018
+
1019
+ # Path filters are not described for 'add' and assumed to have no
1020
+ # meaning - https://tools.ietf.org/html/rfc7644#section-3.5.2.1
1021
+ #
1022
+ elsif filter.present? && nature != 'add'
1023
+ compact_after = false
1024
+ found_matches = false
1025
+
1026
+ all_matching_filter(filter: filter, within_array: current_data_at_path) do | matched_hash, matched_index |
1027
+ found_matches = true
1028
+
1029
+ case nature
1030
+ when 'remove'
1031
+ handled = false
1032
+ attr_map_path = path[..-2] + [path_component]
1033
+ attr_map_entry = with_attr_map.dig(*attr_map_path.map(&:to_sym))
1034
+
1035
+ # Deal with arrays specially; static maps require specific
1036
+ # treatment, but dynamic or actual array values do not.
1037
+ #
1038
+ if attr_map_entry.is_a?(Array)
1039
+ array_attr_map = find_matching_static_attr_map(
1040
+ data: matched_hash,
1041
+ with_attr_map: attr_map_entry
1042
+ )
1043
+
1044
+ # Found? Run through the mapped attributes. Anything that
1045
+ # has an associated model attribute (i.e. some property
1046
+ # that must be to be written into local data in response
1047
+ # to the SCIM attribute being changed) is 'removed' by
1048
+ # setting the corresponding value in "altering_hash" (of
1049
+ # which "matched_hash" referenced fragment) to "nil".
1050
+ #
1051
+ handled = clear_data_for_removal!(
1052
+ altering_hash: matched_hash,
1053
+ with_attr_map: array_attr_map
1054
+ )
1055
+ end
1056
+
1057
+ # For dynamic arrays or other value types, we assume that
1058
+ # just clearing the item from the array or setting its SCIM
1059
+ # attribute to "nil" will result in an appropriate update
1060
+ # to the local data model (e.g. by a change in an Rails
1061
+ # associated collection or clearing a local model attribute
1062
+ # directly to "nil").
1063
+ #
1064
+ if handled == false
1065
+ current_data_at_path[matched_index] = nil
1066
+ compact_after = true
1067
+ end
1068
+
1069
+ when 'replace'
1070
+ matched_hash.reject! { true }
1071
+ matched_hash.merge!(value)
1072
+
1073
+ end
1074
+ end
1075
+
1076
+ current_data_at_path.compact! if compact_after
1077
+
1078
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.1
1079
+ #
1080
+ # o If the target location specifies an attribute that does not exist
1081
+ # (has no value), the attribute is added with the new value.
1082
+ #
1083
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.3
1084
+ #
1085
+ # o If the target location path specifies an attribute that does not
1086
+ # exist, the service provider SHALL treat the operation as an "add".
1087
+ #
1088
+ current_data_at_path << value unless found_matches || nature == 'remove'
1089
+
1090
+ else
1091
+ case nature
1092
+ when 'add'
1093
+ if current_data_at_path.is_a?(Array)
1094
+ altering_hash[path_component] += value
1095
+ elsif current_data_at_path.is_a?(Hash)
1096
+
1097
+ # Need to dive down inside a Hash value for additions; a
1098
+ # deep merge isn't enough. Take this Group example where
1099
+ # nature is "add" and value is:
1100
+ #
1101
+ # "members":[
1102
+ # {
1103
+ # "value":"<user-id>"
1104
+ # }
1105
+ # ]
1106
+ #
1107
+ # ...in that case, a deep merge would *replace* the array
1108
+ # at key 'members' with the above, rather than adding.
1109
+ #
1110
+ value.keys.each do | key |
1111
+ from_patch_backend!(
1112
+ nature: nature,
1113
+ path: path + [key],
1114
+ value: value[key],
1115
+ altering_hash: altering_hash,
1116
+ with_attr_map: with_attr_map
1117
+ )
1118
+ end
1119
+ else
1120
+ altering_hash[path_component] = value
1121
+ end
1122
+
1123
+ when 'replace'
1124
+ if path_component == 'root'
1125
+ dot_pathed_value = value.inject({}) do |hash, (k, v)|
1126
+ hash.deep_merge!(::Scimitar::Support::Utilities.dot_path(k.split('.'), v))
1127
+ end
1128
+
1129
+ altering_hash[path_component].deep_merge!(dot_pathed_value)
1130
+ else
1131
+ altering_hash[path_component] = value
1132
+ end
1133
+
1134
+ # The array check handles payloads seen from e.g. Microsoft for
1135
+ # remove-user-from-group, where contrary to examples in the RFC
1136
+ # which would imply "payload removes all users", there is the
1137
+ # clear intent to remove just one.
1138
+ #
1139
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.2
1140
+ # https://learn.microsoft.com/en-us/azure/active-directory/app-provisioning/use-scim-to-provision-users-and-groups#update-group-remove-members
1141
+ #
1142
+ # Since remove-all in the face of remove-one is destructive, we
1143
+ # do a special check here to see if there's an array value for
1144
+ # the array path that the payload yielded. If so, we can match
1145
+ # each value against array items and remove just those items.
1146
+ #
1147
+ # There is an additional special case to handle a bad example
1148
+ # from Salesforce:
1149
+ #
1150
+ # https://help.salesforce.com/s/articleView?id=sf.identity_scim_manage_groups.htm&type=5
1151
+ #
1152
+ when 'remove'
1153
+ if altering_hash[path_component].is_a?(Array) && value.present?
1154
+
1155
+ # Handle bad Salesforce example. That might be simply a
1156
+ # documentation error, but just in case...
1157
+ #
1158
+ value = value.values.first if (
1159
+ path_component&.downcase == 'members' &&
1160
+ value.is_a?(Hash) &&
1161
+ value.keys.size == 1 &&
1162
+ value.keys.first&.downcase == 'members'
1163
+ )
1164
+
1165
+ # The Microsoft example provides an array of values, but we
1166
+ # may as well cope with a value specified 'flat'. Promote
1167
+ # such a thing to an Array to simplify the following code.
1168
+ #
1169
+ value = [value] unless value.is_a?(Array)
1170
+
1171
+ # For each value item, delete matching array entries. The
1172
+ # concept of "matching" is:
1173
+ #
1174
+ # * For simple non-Hash values (if possible) just delete on
1175
+ # an exact match
1176
+ #
1177
+ # * For Hash-based values, only delete if all 'patch' keys
1178
+ # are present in the resource and all values thus match.
1179
+ #
1180
+ # Special case to ignore '$ref' from the Microsoft payload.
1181
+ #
1182
+ # Note coercion to strings to account for SCIM vs the usual
1183
+ # tricky case of underlying implementations with (say)
1184
+ # integer primary keys, which all end up as strings anyway.
1185
+ #
1186
+ value.each do | value_item |
1187
+ altering_hash[path_component].map! do | item |
1188
+ item_is_matched = if item.is_a?(Hash) && value_item.is_a?(Hash)
1189
+ matched_all = true
1190
+ value_item.each do | value_key, value_value |
1191
+ next if value_key == '$ref'
1192
+ if ! item.key?(value_key) || item[value_key]&.to_s != value_value&.to_s
1193
+ matched_all = false
1194
+ end
1195
+ end
1196
+ matched_all
1197
+ else
1198
+ item&.to_s == value_item&.to_s
1199
+ end
1200
+
1201
+ if item_is_matched
1202
+ handled = false
1203
+ attr_map_path = path[..-2] + [path_component]
1204
+ attr_map_entry = with_attr_map.dig(*attr_map_path.map(&:to_sym))
1205
+ array_attr_map = find_matching_static_attr_map(
1206
+ data: item,
1207
+ with_attr_map: attr_map_entry
1208
+ )
1209
+
1210
+ handled = clear_data_for_removal!(
1211
+ altering_hash: item,
1212
+ with_attr_map: array_attr_map
1213
+ )
1214
+
1215
+ handled ? item : nil
1216
+ else
1217
+ item
1218
+ end
1219
+ end
1220
+
1221
+ altering_hash[path_component].compact!
1222
+ end
1223
+
1224
+ elsif altering_hash[path_component].is_a?(Array)
1225
+ handled = false
1226
+ attr_map_path = path[..-2] + [path_component]
1227
+ attr_map_entry = with_attr_map.dig(*attr_map_path.map(&:to_sym))
1228
+
1229
+ if attr_map_entry.is_a?(Array) # Array mapping
1230
+ altering_hash[path_component].each do | data_to_check |
1231
+ array_attr_map = find_matching_static_attr_map(
1232
+ data: data_to_check,
1233
+ with_attr_map: attr_map_entry
1234
+ )
1235
+
1236
+ handled = clear_data_for_removal!(
1237
+ altering_hash: data_to_check,
1238
+ with_attr_map: array_attr_map
1239
+ )
1240
+ end
1241
+ end
1242
+
1243
+ if handled == false
1244
+ altering_hash[path_component] = []
1245
+ end
1246
+
1247
+ else
1248
+ altering_hash[path_component] = nil
1249
+ end
1250
+
1251
+ end
1252
+ end
1253
+ end
1254
+
1255
+ # Given a path element from SCIM, splits this into the attribute and
1256
+ # filter parts. Returns a tuple of [attribute, filter] where +filter+
1257
+ # will be +nil+ if no filter string was given.
1258
+ #
1259
+ # Named parameters:
1260
+ #
1261
+ # +path_component+:: Path component to examine (a String), e.g.
1262
+ # 'userName' or 'emails[type eq "work"]'.
1263
+ #
1264
+ # Happily throws exceptions if data is not as expected / required.
1265
+ #
1266
+ def extract_filter_from(path_component:)
1267
+ filter = nil
1268
+
1269
+ if path_component.include?('[')
1270
+ composition = path_component.split(/[\[\]]/) # "attribute_name[filter_string]" -> ["attribute_name", "filter_string"]
1271
+ path_component = composition.first
1272
+ filter = composition.last
1273
+ end
1274
+
1275
+ [path_component, filter]
1276
+ end
1277
+
1278
+ # Given a SCIM filter string and array of Hashes from a SCIM object,
1279
+ # search for matches within the array and invoke a given block for
1280
+ # each.
1281
+ #
1282
+ # Obtain filter strings by calling #extract_filter_from.
1283
+ #
1284
+ # TODO: Support more complex matchers than 'attr eq "value"'.
1285
+ #
1286
+ # Named parameters:
1287
+ #
1288
+ # +filter+:: Filter string, e.g. 'type eq "work"'.
1289
+ # +within_array+:: Array to search.
1290
+ #
1291
+ # You must pass a block. It is invoked with each matching array entry
1292
+ # (a Hash) and the index into +within_array+ at which this was found.
1293
+ #
1294
+ # Happily throws exceptions if data is not as expected / required.
1295
+ #
1296
+ def all_matching_filter(filter:, within_array:, &block)
1297
+ filter_components = filter.split(' ', 3)
1298
+
1299
+ attribute = filter_components[0]
1300
+ operator = filter_components[1]
1301
+ value = filter_components[2]
1302
+
1303
+ # Quoted value includes closing quote but has data afterwards?
1304
+ # Bad; implies extra conditions, e.g. '...eq "one two" and..." or
1305
+ # just junk data.
1306
+ #
1307
+ # Value is *not* quoted but contains a space? Means there must be
1308
+ # again either extra conditions or trailing junk data.
1309
+ #
1310
+ raise "Unsupported matcher #{filter.inspect}" if (
1311
+ filter_components.size != 3 ||
1312
+ operator.downcase != 'eq' ||
1313
+ value.strip.match?(/\".+[^\\]\".+/) || # Literal '"', any data, no-backslash-then-literal (unescaped) '"', more data
1314
+ (!value.start_with?('"') && value.strip.include?(' '))
1315
+ )
1316
+
1317
+ value = value[1..-2] if value.start_with?('"') && value.end_with?('"')
1318
+
1319
+ within_array.each.with_index do | hash, index |
1320
+ ci_hash = hash.with_indifferent_case_insensitive_access()
1321
+ matched = ci_hash.key?(attribute) && ci_hash[attribute]&.to_s == value&.to_s
1322
+
1323
+ yield(hash, index) if matched
1324
+ end
1325
+ end
1326
+
1327
+ # Static attribute maps are used where SCIM attributes include some
1328
+ # kind of array, but it's not an arbitrary collection (dynamic maps
1329
+ # handle those). Instead, specific matched values inside the SCIM
1330
+ # data are mapped to specific attributes in the local data model.
1331
+ #
1332
+ # A typical example is for e-mails, where the SCIM "type" field in an
1333
+ # array of e-mail addresses might get mapped to detect specific types
1334
+ # of address such as "work" and "home", which happen to be stored
1335
+ # locally in dedicated attributes (e.g. "work_email_address").
1336
+ #
1337
+ # During certain processing operations we end up with a set of data
1338
+ # sent in from some SCIM operation and need to make modifications
1339
+ # (e.g. for a PATCH) that require the attribute map corresponding to
1340
+ # each part of the inbound SCIM data to be known. That's where this
1341
+ # method comes in. Usually, it's not hard to traverse a path of SCIM
1342
+ # data and dig a corresponding path through the attribute map Hash,
1343
+ # except for static arrays. There, we need to know which of the
1344
+ # static map entries matches a piece of SCIM data *from entries* in
1345
+ # the array of SCIM data corresponding to the static map.
1346
+ #
1347
+ # Call here with a piece of SCIM data from an array, along with an
1348
+ # attribute map fragment that must be the Array containing mappings.
1349
+ # Static mapping entries from this are compared with the data and if
1350
+ # a match is found, the sub-attribute map from the static entry's
1351
+ # <tt>:using</tt> key is returned; else +nil+.
1352
+ #
1353
+ # Named parameters are:
1354
+ #
1355
+ # +data+:: A SCIM data entry from a SCIM data array which is
1356
+ # mapped via the data given in the +with_attr_map+
1357
+ # parameter.
1358
+ #
1359
+ # +with_attr_map+:: The attributes map fragment which must be an
1360
+ # Array of mappings for the corresponding array
1361
+ # in the SCIM data from which +data+ was drawn.
1362
+ #
1363
+ # For example, if SCIM data consisted of:
1364
+ #
1365
+ # {
1366
+ # 'emails' => [
1367
+ # {
1368
+ # 'type' => 'work',
1369
+ # 'value' => 'work_1@test.com'
1370
+ # },
1371
+ # {
1372
+ # 'type' => 'work',
1373
+ # 'value' => 'work_2@test.com'
1374
+ # }
1375
+ # ]
1376
+ # }
1377
+ #
1378
+ # ...which was mapped to the local data model using the following
1379
+ # attribute map:
1380
+ #
1381
+ # {
1382
+ # emails: [
1383
+ # { match: 'type', with: 'home', using: { value: :home_email } },
1384
+ # { match: 'type', with: 'work', using: { value: :work_email } },
1385
+ # ]
1386
+ # }
1387
+ #
1388
+ # ...then when it came to processing the SCIM 'emails' entry, one of
1389
+ # the array _entries_ therein would be passed in +data+, while the
1390
+ # attribute map's <tt>:emails</tt> key's value (the _array_ of map
1391
+ # data) would be given in <tt>:with_attr_map</tt>. The first SCIM
1392
+ # array entry matches +work+ so the <tt>:using</tt> part of the map
1393
+ # for that match would be returned:
1394
+ #
1395
+ # { value: :work_email }
1396
+ #
1397
+ # If there was a SCIM entry with a type of something unrecognised,
1398
+ # such as 'holday', then +nil+ would be returned since there is no
1399
+ # matching attribute map entry.
1400
+ #
1401
+ # Note that the <tt>:with_attr_map</tt> array can contain dynamic
1402
+ # mappings or even be just a simple fixed array - only things that
1403
+ # "look like" static mapping entries are processed (i.e. Hashes with
1404
+ # a Symbol key of <tt>:match</tt> present), with the rest ignored.
1405
+ #
1406
+ def find_matching_static_attr_map(data:, with_attr_map:)
1407
+ matched_map = with_attr_map.find do | static_or_dynamic_mapping |
1408
+
1409
+ # Only interested in Static Array mappings.
1410
+ #
1411
+ if static_or_dynamic_mapping.is_a?(Hash) && static_or_dynamic_mapping.key?(:match)
1412
+
1413
+ attr_to_match = static_or_dynamic_mapping[:match].to_s
1414
+ value_to_match = static_or_dynamic_mapping[:with]
1415
+ sub_attrs_map = static_or_dynamic_mapping[:using]
1416
+
1417
+ # If this mapping refers to the matched data at hand,
1418
+ # then we can process it further (see later below.
1419
+ #
1420
+ found = data[attr_to_match] == value_to_match
1421
+
1422
+ # Not found? No static map match perhaps; this could be
1423
+ # because a filter worked on a value which is fixed in
1424
+ # the static map. For example, a filter might check for
1425
+ # emails with "primary true", and the emergence of the
1426
+ # value for "primary" might not be in the data model -
1427
+ # it could be a constant declared in the 'using' part
1428
+ # of a static map. Ugh! Check for that.
1429
+ #
1430
+ unless found
1431
+ sub_attrs_map.each do | scim_attr, model_attr_or_constant |
1432
+
1433
+ # Only want constants such as 'true' or 'false'.
1434
+ #
1435
+ next if model_attr_or_constant.is_a?(Symbol)
1436
+
1437
+ # Does the static value match in the source data?
1438
+ # E.g. a SCIM attribute :primary with value 'true'.
1439
+ #
1440
+ if data[scim_attr] == model_attr_or_constant
1441
+ found = true
1442
+ break
1443
+ end
1444
+ end
1445
+ end
1446
+
1447
+ found
1448
+ else
1449
+ false
1450
+ end
1451
+ end
1452
+
1453
+ return matched_map&.dig(:using)
1454
+ end
1455
+
1456
+ # Related to #find_matching_static_attr_map - often, the reason to
1457
+ # find a static array entry related to some inbound SCIM data is for
1458
+ # a removal operation, where the way to "remove" the data in the
1459
+ # local data model is to set an attribute to "nil". This means you
1460
+ # need to know if there is an attribute writer related to the SCIM
1461
+ # data being removed - and #find_matching_static_attr_map helps.
1462
+ #
1463
+ # With that done, you can call here with the hash data to be changed
1464
+ # and fragment of attribute map that #find_matching_static_attr_map
1465
+ # (or something like it) found.
1466
+ #
1467
+ # +altering_hash+:: The fragment of SCIM data that might be updated
1468
+ # with +nil+ to ultimately lead to an atttribute
1469
+ # writer identified through +with_attr_map+ being
1470
+ # called with that value. This is often the same
1471
+ # that was passed in the +data+ attribute in a
1472
+ # prior #find_matching_static_attr_map call.
1473
+ #
1474
+ # +with_attr_map:: The map fragment that corresponds exactly to the
1475
+ # +altering_hash+ data - e.g. the return value of a
1476
+ # prior #find_matching_static_attr_map call.
1477
+ #
1478
+ # Update +altering_hash+ in place if the map finds a relevant local
1479
+ # data model attribute and returns +true+. If no changes are made,
1480
+ # returns +false+.
1481
+ #
1482
+ def clear_data_for_removal!(altering_hash:, with_attr_map:)
1483
+ handled = false
1484
+
1485
+ with_attr_map&.each do | scim_attr, model_attr_or_constant |
1486
+
1487
+ # Only process attribute names, not constants.
1488
+ #
1489
+ next unless model_attr_or_constant.is_a?(Symbol)
1490
+
1491
+ altering_hash[scim_attr] = nil
1492
+ handled = true
1493
+ end
1494
+
1495
+ return handled
1496
+ end
1497
+
1498
+ #
1499
+ # Related to to_scim_backend, this methods tells whether `attribute_path`
1500
+ # should be included in the current `include_attributes`. This method
1501
+ # implements the attributes request from RFC 7644, section 3.9 and 3.10.
1502
+ #
1503
+ # +include_attributes+:: The attributes that should be included
1504
+ # in the response, in the form of a list of
1505
+ # full attribute paths. See RFC 7644 section
1506
+ # 3.9 and section 3.10.
1507
+ # An empty collection will include all attributes.
1508
+ #
1509
+ # +attribute_path+:: Array of path components to the
1510
+ # attribute. I.e.: ["name", "givenName"]
1511
+ #
1512
+ def attribute_included?(include_attributes:, attribute_path:)
1513
+ return true unless attribute_path.any? && include_attributes.any?
1514
+
1515
+ full_path = attribute_path.join(".")
1516
+ attribute_included = full_path.start_with?(*include_attributes)
1517
+ will_include_nested = include_attributes.any? { |att| att.start_with?(full_path) }
1518
+
1519
+ attribute_included || will_include_nested
1520
+ end
1521
+ end # "included do"
1522
+ end # "module Mixin"
1523
+ end # "module Resources"
1524
+ end # "module Scimitar"