scimitar 2.5.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +721 -0
- data/app/controllers/scimitar/active_record_backed_resources_controller.rb +72 -18
- data/app/controllers/scimitar/application_controller.rb +17 -9
- data/app/controllers/scimitar/resource_types_controller.rb +7 -3
- data/app/controllers/scimitar/resources_controller.rb +0 -2
- data/app/controllers/scimitar/schemas_controller.rb +366 -3
- data/app/controllers/scimitar/service_provider_configurations_controller.rb +3 -2
- data/app/models/scimitar/complex_types/address.rb +0 -6
- data/app/models/scimitar/complex_types/base.rb +2 -2
- data/app/models/scimitar/engine_configuration.rb +3 -1
- data/app/models/scimitar/lists/query_parser.rb +97 -12
- data/app/models/scimitar/resource_invalid_error.rb +1 -1
- data/app/models/scimitar/resource_type.rb +4 -6
- data/app/models/scimitar/resources/base.rb +52 -8
- data/app/models/scimitar/resources/mixin.rb +539 -76
- data/app/models/scimitar/schema/attribute.rb +18 -8
- data/app/models/scimitar/schema/base.rb +2 -2
- data/app/models/scimitar/schema/name.rb +2 -2
- data/app/models/scimitar/schema/user.rb +10 -10
- data/config/initializers/scimitar.rb +49 -3
- data/lib/scimitar/engine.rb +57 -12
- data/lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb +140 -10
- data/lib/scimitar/support/utilities.rb +111 -0
- data/lib/scimitar/version.rb +2 -2
- data/lib/scimitar.rb +1 -0
- data/spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb +25 -0
- data/spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb +25 -0
- data/spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb +24 -0
- data/spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb +25 -0
- data/spec/apps/dummy/app/models/mock_user.rb +20 -3
- data/spec/apps/dummy/config/application.rb +8 -0
- data/spec/apps/dummy/config/initializers/scimitar.rb +40 -3
- data/spec/apps/dummy/config/routes.rb +18 -1
- data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +2 -0
- data/spec/apps/dummy/db/schema.rb +3 -1
- data/spec/controllers/scimitar/application_controller_spec.rb +56 -2
- data/spec/controllers/scimitar/resource_types_controller_spec.rb +8 -4
- data/spec/controllers/scimitar/schemas_controller_spec.rb +344 -48
- data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +1 -0
- data/spec/models/scimitar/complex_types/address_spec.rb +3 -4
- data/spec/models/scimitar/lists/query_parser_spec.rb +70 -0
- data/spec/models/scimitar/resources/base_spec.rb +55 -13
- data/spec/models/scimitar/resources/base_validation_spec.rb +16 -3
- data/spec/models/scimitar/resources/mixin_spec.rb +781 -124
- data/spec/models/scimitar/schema/attribute_spec.rb +22 -0
- data/spec/models/scimitar/schema/user_spec.rb +2 -2
- data/spec/requests/active_record_backed_resources_controller_spec.rb +723 -40
- data/spec/requests/engine_spec.rb +75 -0
- data/spec/spec_helper.rb +10 -2
- data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +108 -0
- metadata +42 -34
@@ -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:
|
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 =
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
-
|
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
|
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
|
-
# -
|
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
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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
|
40
|
-
self.class.extended_schemas.
|
41
|
-
|
42
|
-
|
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
|
-
|
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
|
161
|
+
if scim_attribute&.complexType
|
131
162
|
if scim_attribute.multiValued
|
132
|
-
self.send("#{attr_name}=", attr_value
|
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
|
|