scimitar 1.8.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -20
  3. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +5 -4
  4. data/app/controllers/scimitar/resource_types_controller.rb +0 -2
  5. data/app/controllers/scimitar/resources_controller.rb +0 -2
  6. data/app/controllers/scimitar/schemas_controller.rb +361 -3
  7. data/app/controllers/scimitar/service_provider_configurations_controller.rb +0 -1
  8. data/app/models/scimitar/engine_configuration.rb +3 -1
  9. data/app/models/scimitar/lists/query_parser.rb +88 -3
  10. data/app/models/scimitar/resources/base.rb +48 -14
  11. data/app/models/scimitar/resources/mixin.rb +531 -71
  12. data/app/models/scimitar/schema/name.rb +2 -2
  13. data/app/models/scimitar/schema/user.rb +10 -10
  14. data/config/initializers/scimitar.rb +41 -0
  15. data/lib/scimitar/engine.rb +57 -12
  16. data/lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb +140 -10
  17. data/lib/scimitar/support/utilities.rb +60 -0
  18. data/lib/scimitar/version.rb +2 -2
  19. data/spec/apps/dummy/app/models/mock_user.rb +18 -3
  20. data/spec/apps/dummy/config/initializers/scimitar.rb +31 -2
  21. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +1 -0
  22. data/spec/apps/dummy/db/schema.rb +1 -0
  23. data/spec/controllers/scimitar/schemas_controller_spec.rb +342 -54
  24. data/spec/models/scimitar/lists/query_parser_spec.rb +70 -0
  25. data/spec/models/scimitar/resources/base_spec.rb +20 -12
  26. data/spec/models/scimitar/resources/base_validation_spec.rb +16 -3
  27. data/spec/models/scimitar/resources/mixin_spec.rb +754 -122
  28. data/spec/models/scimitar/schema/user_spec.rb +2 -2
  29. data/spec/requests/active_record_backed_resources_controller_spec.rb +312 -5
  30. data/spec/requests/engine_spec.rb +75 -0
  31. data/spec/spec_helper.rb +1 -1
  32. data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +108 -0
  33. metadata +22 -22
@@ -59,13 +59,18 @@ module Scimitar
59
59
  #
60
60
  BINARY_OPERATORS = Set.new(OPERATORS.keys.reject { |op| UNARY_OPERATORS.include?(op) }).freeze
61
61
 
62
+ # Precompiled expression that matches a valid attribute name according to
63
+ # https://tools.ietf.org/html/rfc7643#section-2.1.
64
+ #
65
+ ATTRNAME = /[[:alpha:]][[:alnum:]$-_]*/
66
+
62
67
  # =======================================================================
63
68
  # Tokenizing expressions
64
69
  # =======================================================================
65
70
 
66
71
  PAREN = /[\(\)]/.freeze
67
72
  STR = /"(?:\\"|[^"])*"/.freeze
68
- OP = /#{OPERATORS.keys.join('|')}/i.freeze
73
+ OP = /(?:#{OPERATORS.keys.join('|')})\b/i.freeze
69
74
  WORD = /[\w\.]+/.freeze
70
75
  SEP = /\s?/.freeze
71
76
  NEXT_TOKEN = /\A(#{PAREN}|#{STR}|#{OP}|#{WORD})#{SEP}/.freeze
@@ -291,6 +296,10 @@ module Scimitar
291
296
  # the part before the "[" as a prefix - "emails[type" to "emails.type",
292
297
  # with similar substitutions therein.
293
298
  #
299
+ # Further, via https://github.com/RIPAGlobal/scimitar/issues/115 we see
300
+ # a requirement to support a broken form emitted by Microsoft; that is
301
+ # supported herein.
302
+ #
294
303
  # This method tries to flatten things thus. It throws exceptions if any
295
304
  # problems arise at all. Some limitations:
296
305
  #
@@ -314,6 +323,9 @@ module Scimitar
314
323
  # <- userType ne "Employee" and not (emails[value co "example.com" or (value co "example.org")]) and userName="foo"
315
324
  # => userType ne "Employee" and not (emails.value co "example.com" or (emails.value co "example.org")) and userName="foo"
316
325
  #
326
+ # <- emails[type eq "work"].value eq "foo@bar.com" (Microsoft workaround)
327
+ # => emails.type eq "work" and emails.value eq "foo@bar.com"
328
+ #
317
329
  # +filter+:: Input filter string. Returns a possibly modified String,
318
330
  # with the hopefully equivalent but flattened filter inside.
319
331
  #
@@ -363,9 +375,53 @@ module Scimitar
363
375
  end
364
376
 
365
377
  elsif (expecting_value)
366
- matches = downcased.match(/([^\\])\]/) # Contains no-backslash-then-literal (unescaped) ']'
378
+ matches = downcased.match(/([^\\])\](.*)/) # Contains no-backslash-then-literal (unescaped) ']'; also capture anything after
367
379
  unless matches.nil? # Contains no-backslash-then-literal (unescaped) ']'
368
380
  character_before_closing_bracket = matches[1]
381
+ characters_after_closing_bracket = matches[2]
382
+
383
+ # https://github.com/RIPAGlobal/scimitar/issues/115 - detect
384
+ # bad Microsoft filters. After the closing bracket, we expect a
385
+ # dot then valid attribute characters and at least one white
386
+ # space character and filter operator, but we split on spaces,
387
+ # so the next item in the components array must be a recognised
388
+ # operator for the special case code to kick in.
389
+ #
390
+ # If this all works out, we transform this section of the
391
+ # filter string into a local dotted form, reconstruct the
392
+ # overall filter with this substitution, and call back to this
393
+ # method with that modified filter, returning the result.
394
+ #
395
+ # So - NOTE RECURSION AND EARLY EXIT POSSIBILITY HEREIN.
396
+ #
397
+ if (
398
+ ! attribute_prefix.nil? &&
399
+ OPERATORS.key?(components[index + 1]&.downcase) &&
400
+ characters_after_closing_bracket.match?(/^\.#{ATTRNAME}$/)
401
+ )
402
+ # E.g. '"work"' and '.value' from input '"work"].value'
403
+ #
404
+ component_matches = component.match(/^(.*?[^\\])\](.*)/)
405
+ part_before_closing_bracket = component_matches[1]
406
+ part_after_closing_bracket = component_matches[2]
407
+
408
+ # Produces e.g. '"work"] and emails.value'
409
+ #
410
+ dotted_version = "#{part_before_closing_bracket}] and #{attribute_prefix}#{part_after_closing_bracket}"
411
+
412
+ # Overwrite the components array entry with this new version.
413
+ #
414
+ components[index] = dotted_version
415
+
416
+ # Join it back again as a reconstructed valid filter string.
417
+ #
418
+ hopefully_valid_filter = components.join(' ')
419
+
420
+ # NOTE EARLY EXIT
421
+ #
422
+ return flatten_filter(hopefully_valid_filter)
423
+ end
424
+
369
425
  component.gsub!(/[^\\]\]/, character_before_closing_bracket)
370
426
 
371
427
  if expecting_closing_bracket
@@ -410,7 +466,36 @@ module Scimitar
410
466
  end
411
467
  end
412
468
 
413
- return rewritten.join(' ')
469
+ # Handle schema IDs.
470
+ #
471
+ # Scimitar currently has a limitation where it strips schema IDs in
472
+ # things like PATCH operation path traversal; see
473
+ # https://github.com/RIPAGlobal/scimitar/issues/130. At least that
474
+ # makes things easy here; use the same approach and strip them out!
475
+ #
476
+ # We don't know which resource is being queried at this layer of the
477
+ # software. If Scimitar were to bump major version, then an extension
478
+ # to QueryParser#parse to include this information would be wise. In
479
+ # the mean time, all we can do is enumerate all extension schema
480
+ # subclasses with IDs and remove those IDs if present in the filter.
481
+ #
482
+ # Inbound unrecognised schema IDs will be left behind. If the client
483
+ # Scimitar application hasn't defined requested schemas, it would
484
+ # very likely never have been able to handle the filter either way.
485
+ #
486
+ rewritten_joined = rewritten.join(' ')
487
+ if rewritten_joined.include?(':')
488
+
489
+ # README.md notes that extensions *must* be a subclass of
490
+ # Scimitar::Schema::Base and must define IDs.
491
+ #
492
+ known_schema_ids = Scimitar::Schema::Base.subclasses.map { |s| s.new.id }.compact
493
+ known_schema_ids.each do | schema_id |
494
+ rewritten_joined.gsub!(/#{schema_id}[\:\.]/, '')
495
+ end
496
+ end
497
+
498
+ return rewritten_joined
414
499
  end
415
500
 
416
501
  # Service method to DRY up #flatten_filter a little. Applies a prefix
@@ -35,14 +35,45 @@ module Scimitar
35
35
  @errors = ActiveModel::Errors.new(self)
36
36
  end
37
37
 
38
+ # Scimitar has at present a general limitation in handling schema IDs,
39
+ # which just involves stripping them and requiring attributes across all
40
+ # extension schemas to be overall unique.
41
+ #
42
+ # This method takes an options payload for the initializer and strips out
43
+ # *recognised* schema IDs, so that the resulting attribute data matches
44
+ # the resource attribute map.
45
+ #
46
+ # +attributes+:: Attributes to assign via initializer; typically a POST
47
+ # payload of attributes that has been run through Rails
48
+ # strong parameters for safety.
49
+ #
50
+ # Returns a new object of the same class as +options+ with recognised
51
+ # schema IDs removed.
52
+ #
38
53
  def flatten_extension_attributes(options)
39
- flattened = options.dup
40
- self.class.extended_schemas.each do |extended_schema|
41
- if extension_attrs = flattened.delete(extended_schema.id)
42
- flattened.merge!(extension_attrs)
54
+ flattened = options.class.new
55
+ lower_case_schema_ids = self.class.extended_schemas.map do | schema |
56
+ schema.id.downcase()
57
+ end
58
+
59
+ options.each do | key, value |
60
+ path = Scimitar::Support::Utilities::path_str_to_array(
61
+ self.class.extended_schemas,
62
+ key
63
+ )
64
+
65
+ if path.first.include?(':') && lower_case_schema_ids.include?(path.first.downcase)
66
+ path.shift()
67
+ end
68
+
69
+ if path.empty?
70
+ flattened.merge!(value)
71
+ else
72
+ flattened[path.join('.')] = value
43
73
  end
44
74
  end
45
- flattened
75
+
76
+ return flattened
46
77
  end
47
78
 
48
79
  # Can be used to extend an existing resource type's schema. For example:
@@ -129,7 +160,7 @@ module Scimitar
129
160
 
130
161
  if scim_attribute && scim_attribute.complexType
131
162
  if scim_attribute.multiValued
132
- self.send("#{attr_name}=", attr_value.map {|attr_for_each_item| complex_type_from_hash(scim_attribute, attr_for_each_item)})
163
+ self.send("#{attr_name}=", attr_value&.map {|attr_for_each_item| complex_type_from_hash(scim_attribute, attr_for_each_item)})
133
164
  else
134
165
  self.send("#{attr_name}=", complex_type_from_hash(scim_attribute, attr_value))
135
166
  end
@@ -137,18 +168,21 @@ module Scimitar
137
168
  end
138
169
  end
139
170
 
171
+ # Renders *in full* as JSON; typically used for write-based operations...
172
+ #
173
+ # record = self.storage_class().new
174
+ # record.from_scim!(scim_hash: scim_resource.as_json())
175
+ # self.save!(record)
176
+ #
177
+ # ...so all fields, even those marked "returned: false", are included.
178
+ # Use Scimitar::Resources::Mixin::to_scim to obtain a SCIM object with
179
+ # non-returnable fields omitted, rendering *that* as JSON via #to_json.
180
+ #
140
181
  def as_json(options = {})
141
182
  self.meta = Meta.new unless self.meta && self.meta.is_a?(Meta)
142
183
  self.meta.resourceType = self.class.resource_type_id
143
184
 
144
- non_returnable_attributes = self.class
145
- .schemas
146
- .flat_map(&:scim_attributes)
147
- .filter_map { |attribute| attribute.name if attribute.returned == 'never' }
148
-
149
- non_returnable_attributes << 'errors'
150
-
151
- original_hash = super(options).except(*non_returnable_attributes)
185
+ original_hash = super(options).except('errors')
152
186
  original_hash.merge!('schemas' => self.class.schemas.map(&:id))
153
187
 
154
188
  self.class.extended_schemas.each do |extension_schema|