scimitar 2.5.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -0
  3. data/README.md +721 -0
  4. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +72 -18
  5. data/app/controllers/scimitar/application_controller.rb +17 -9
  6. data/app/controllers/scimitar/resource_types_controller.rb +7 -3
  7. data/app/controllers/scimitar/resources_controller.rb +0 -2
  8. data/app/controllers/scimitar/schemas_controller.rb +366 -3
  9. data/app/controllers/scimitar/service_provider_configurations_controller.rb +3 -2
  10. data/app/models/scimitar/complex_types/address.rb +0 -6
  11. data/app/models/scimitar/complex_types/base.rb +2 -2
  12. data/app/models/scimitar/engine_configuration.rb +3 -1
  13. data/app/models/scimitar/lists/query_parser.rb +97 -12
  14. data/app/models/scimitar/resource_invalid_error.rb +1 -1
  15. data/app/models/scimitar/resource_type.rb +4 -6
  16. data/app/models/scimitar/resources/base.rb +52 -8
  17. data/app/models/scimitar/resources/mixin.rb +539 -76
  18. data/app/models/scimitar/schema/attribute.rb +18 -8
  19. data/app/models/scimitar/schema/base.rb +2 -2
  20. data/app/models/scimitar/schema/name.rb +2 -2
  21. data/app/models/scimitar/schema/user.rb +10 -10
  22. data/config/initializers/scimitar.rb +49 -3
  23. data/lib/scimitar/engine.rb +57 -12
  24. data/lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb +140 -10
  25. data/lib/scimitar/support/utilities.rb +111 -0
  26. data/lib/scimitar/version.rb +2 -2
  27. data/lib/scimitar.rb +1 -0
  28. data/spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb +25 -0
  29. data/spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb +25 -0
  30. data/spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb +24 -0
  31. data/spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb +25 -0
  32. data/spec/apps/dummy/app/models/mock_user.rb +20 -3
  33. data/spec/apps/dummy/config/application.rb +8 -0
  34. data/spec/apps/dummy/config/initializers/scimitar.rb +40 -3
  35. data/spec/apps/dummy/config/routes.rb +18 -1
  36. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +2 -0
  37. data/spec/apps/dummy/db/schema.rb +3 -1
  38. data/spec/controllers/scimitar/application_controller_spec.rb +56 -2
  39. data/spec/controllers/scimitar/resource_types_controller_spec.rb +8 -4
  40. data/spec/controllers/scimitar/schemas_controller_spec.rb +344 -48
  41. data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +1 -0
  42. data/spec/models/scimitar/complex_types/address_spec.rb +3 -4
  43. data/spec/models/scimitar/lists/query_parser_spec.rb +70 -0
  44. data/spec/models/scimitar/resources/base_spec.rb +55 -13
  45. data/spec/models/scimitar/resources/base_validation_spec.rb +16 -3
  46. data/spec/models/scimitar/resources/mixin_spec.rb +781 -124
  47. data/spec/models/scimitar/schema/attribute_spec.rb +22 -0
  48. data/spec/models/scimitar/schema/user_spec.rb +2 -2
  49. data/spec/requests/active_record_backed_resources_controller_spec.rb +723 -40
  50. data/spec/requests/engine_spec.rb +75 -0
  51. data/spec/spec_helper.rb +10 -2
  52. data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +108 -0
  53. metadata +42 -34
@@ -14,6 +14,7 @@ module Scimitar
14
14
  :application_controller_mixin,
15
15
  :exception_reporter,
16
16
  :optional_value_fields_required,
17
+ :schema_list_from_attribute_mappings,
17
18
  )
18
19
 
19
20
  def initialize(attributes = {})
@@ -22,7 +23,8 @@ module Scimitar
22
23
  # Set defaults that may be overridden by the initializer.
23
24
  #
24
25
  defaults = {
25
- optional_value_fields_required: true
26
+ optional_value_fields_required: true,
27
+ schema_list_from_attribute_mappings: []
26
28
  }
27
29
 
28
30
  super(defaults.merge(attributes))
@@ -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/pond/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
  #
@@ -330,14 +342,14 @@ module Scimitar
330
342
  skip_next_component = false
331
343
 
332
344
  components.each.with_index do | component, index |
333
- if skip_next_component == true
345
+ if skip_next_component
334
346
  skip_next_component = false
335
347
  next
336
348
  end
337
349
 
338
350
  downcased = component.downcase.strip
339
351
 
340
- if (expecting_attribute)
352
+ if expecting_attribute
341
353
  if downcased.match?(/[^\\]\[/) # Not backslash then literal '['
342
354
  attribute_prefix = component.match(/(.*?[^\\])\[/ )[1] # Everything before no-backslash-then-literal (unescaped) '['
343
355
  first_attribute_inside = component.match( /[^\\]\[(.*)/)[1] # Everything after no-backslash-then-literal (unescaped) '['
@@ -350,7 +362,7 @@ module Scimitar
350
362
  expecting_attribute = false
351
363
  expecting_operator = true
352
364
 
353
- elsif (expecting_operator)
365
+ elsif expecting_operator
354
366
  rewritten << component
355
367
  if BINARY_OPERATORS.include?(downcased)
356
368
  expecting_operator = false
@@ -362,10 +374,54 @@ module Scimitar
362
374
  raise 'Expected operator'
363
375
  end
364
376
 
365
- elsif (expecting_value)
366
- matches = downcased.match(/([^\\])\]/) # Contains no-backslash-then-literal (unescaped) ']'
377
+ elsif expecting_value
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/pond/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
@@ -381,7 +437,7 @@ module Scimitar
381
437
  if downcased.start_with?('"')
382
438
  expecting_closing_quote = true
383
439
  downcased = downcased[1..-1] # Strip off opening '"' to avoid false-positive on 'contains closing quote' check below
384
- elsif expecting_closing_quote == false # If not expecting a closing quote, then the component must be the entire no-spaces value
440
+ elsif !expecting_closing_quote # If not expecting a closing quote, then the component must be the entire no-spaces value
385
441
  expecting_value = false
386
442
  expecting_logic_word = true
387
443
  end
@@ -394,7 +450,7 @@ module Scimitar
394
450
  end
395
451
  end
396
452
 
397
- elsif (expecting_logic_word)
453
+ elsif expecting_logic_word
398
454
  if downcased == 'and' || downcased == 'or'
399
455
  rewritten << component
400
456
  next_downcased_component = components[index + 1].downcase.strip
@@ -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/pond/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
@@ -501,11 +586,11 @@ module Scimitar
501
586
 
502
587
  # Recursively process an expression tree. Calls itself with nested tree
503
588
  # fragments. Each inner expression fragment calculates on the given
504
- # base scope, with aggregration at each level into a wider query using
589
+ # base scope, with aggregation at each level into a wider query using
505
590
  # AND or OR depending on the expression tree contents.
506
591
  #
507
592
  # +base_scope+:: Base scope (ActiveRecord::Relation, e.g. User.all
508
- # - neverchanges during recursion).
593
+ # - never changes during recursion).
509
594
  #
510
595
  # +expression_tree+:: Top-level expression tree or fragments inside if
511
596
  # self-calling recursively.
@@ -663,7 +748,7 @@ module Scimitar
663
748
 
664
749
  # Returns the mapped-to-your-domain column name(s) that a filter string
665
750
  # is operating upon, in an Array. If empty, the attribute is to be
666
- # ignored. Raises an exception if entirey unmapped (thus unsupported).
751
+ # ignored. Raises an exception if entirely unmapped (thus unsupported).
667
752
  #
668
753
  # Note plural - the return value is always an array any of which should
669
754
  # be used (implicit 'OR').
@@ -2,7 +2,7 @@ module Scimitar
2
2
  class ResourceInvalidError < ErrorResponse
3
3
 
4
4
  def initialize(error_message)
5
- super(status: 400, scimType: 'invalidValue', detail:"Operation failed since record has become invalid: #{error_message}")
5
+ super(status: 400, scimType: 'invalidValue', detail: "Operation failed since record has become invalid: #{error_message}")
6
6
  end
7
7
 
8
8
  end
@@ -17,12 +17,10 @@ module Scimitar
17
17
 
18
18
  def as_json(options = {})
19
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
20
+ return without_extensions unless schemaExtensions.present? # NOTE EARLY EXIT
21
+
22
+ extensions = schemaExtensions.map{|extension| {"schema" => extension, "required" => false}}
23
+ without_extensions.merge('schemaExtensions' => extensions)
26
24
  end
27
25
 
28
26
  end
@@ -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:
@@ -127,9 +158,9 @@ module Scimitar
127
158
  hash.with_indifferent_access.each_pair do |attr_name, attr_value|
128
159
  scim_attribute = self.class.complex_scim_attributes[attr_name].try(:first)
129
160
 
130
- if scim_attribute && scim_attribute.complexType
161
+ if 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,15 +168,28 @@ 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
- meta.resourceType = self.class.resource_type_id
183
+ self.meta.resourceType = self.class.resource_type_id
184
+
143
185
  original_hash = super(options).except('errors')
144
186
  original_hash.merge!('schemas' => self.class.schemas.map(&:id))
187
+
145
188
  self.class.extended_schemas.each do |extension_schema|
146
189
  extension_attributes = extension_schema.scim_attributes.map(&:name)
147
190
  original_hash.merge!(extension_schema.id => original_hash.extract!(*extension_attributes))
148
191
  end
192
+
149
193
  original_hash
150
194
  end
151
195