scimitar 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (106) hide show
  1. checksums.yaml +7 -0
  2. data/Rakefile +16 -0
  3. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +180 -0
  4. data/app/controllers/scimitar/application_controller.rb +129 -0
  5. data/app/controllers/scimitar/resource_types_controller.rb +28 -0
  6. data/app/controllers/scimitar/resources_controller.rb +203 -0
  7. data/app/controllers/scimitar/schemas_controller.rb +16 -0
  8. data/app/controllers/scimitar/service_provider_configurations_controller.rb +8 -0
  9. data/app/models/scimitar/authentication_error.rb +9 -0
  10. data/app/models/scimitar/authentication_scheme.rb +18 -0
  11. data/app/models/scimitar/bulk.rb +8 -0
  12. data/app/models/scimitar/complex_types/address.rb +18 -0
  13. data/app/models/scimitar/complex_types/base.rb +41 -0
  14. data/app/models/scimitar/complex_types/email.rb +12 -0
  15. data/app/models/scimitar/complex_types/entitlement.rb +12 -0
  16. data/app/models/scimitar/complex_types/ims.rb +12 -0
  17. data/app/models/scimitar/complex_types/name.rb +12 -0
  18. data/app/models/scimitar/complex_types/phone_number.rb +12 -0
  19. data/app/models/scimitar/complex_types/photo.rb +12 -0
  20. data/app/models/scimitar/complex_types/reference_group.rb +12 -0
  21. data/app/models/scimitar/complex_types/reference_member.rb +12 -0
  22. data/app/models/scimitar/complex_types/role.rb +12 -0
  23. data/app/models/scimitar/complex_types/x509_certificate.rb +12 -0
  24. data/app/models/scimitar/engine_configuration.rb +24 -0
  25. data/app/models/scimitar/error_response.rb +20 -0
  26. data/app/models/scimitar/errors.rb +14 -0
  27. data/app/models/scimitar/filter.rb +11 -0
  28. data/app/models/scimitar/filter_error.rb +22 -0
  29. data/app/models/scimitar/invalid_syntax_error.rb +9 -0
  30. data/app/models/scimitar/lists/count.rb +64 -0
  31. data/app/models/scimitar/lists/query_parser.rb +730 -0
  32. data/app/models/scimitar/meta.rb +7 -0
  33. data/app/models/scimitar/not_found_error.rb +10 -0
  34. data/app/models/scimitar/resource_invalid_error.rb +9 -0
  35. data/app/models/scimitar/resource_type.rb +29 -0
  36. data/app/models/scimitar/resources/base.rb +159 -0
  37. data/app/models/scimitar/resources/group.rb +13 -0
  38. data/app/models/scimitar/resources/mixin.rb +964 -0
  39. data/app/models/scimitar/resources/user.rb +13 -0
  40. data/app/models/scimitar/schema/address.rb +24 -0
  41. data/app/models/scimitar/schema/attribute.rb +123 -0
  42. data/app/models/scimitar/schema/base.rb +86 -0
  43. data/app/models/scimitar/schema/derived_attributes.rb +24 -0
  44. data/app/models/scimitar/schema/email.rb +10 -0
  45. data/app/models/scimitar/schema/entitlement.rb +10 -0
  46. data/app/models/scimitar/schema/group.rb +27 -0
  47. data/app/models/scimitar/schema/ims.rb +10 -0
  48. data/app/models/scimitar/schema/name.rb +20 -0
  49. data/app/models/scimitar/schema/phone_number.rb +10 -0
  50. data/app/models/scimitar/schema/photo.rb +10 -0
  51. data/app/models/scimitar/schema/reference_group.rb +23 -0
  52. data/app/models/scimitar/schema/reference_member.rb +21 -0
  53. data/app/models/scimitar/schema/role.rb +10 -0
  54. data/app/models/scimitar/schema/user.rb +52 -0
  55. data/app/models/scimitar/schema/vdtp.rb +18 -0
  56. data/app/models/scimitar/schema/x509_certificate.rb +22 -0
  57. data/app/models/scimitar/service_provider_configuration.rb +49 -0
  58. data/app/models/scimitar/supportable.rb +14 -0
  59. data/app/views/layouts/scimitar/application.html.erb +14 -0
  60. data/config/initializers/scimitar.rb +82 -0
  61. data/config/routes.rb +6 -0
  62. data/lib/scimitar.rb +23 -0
  63. data/lib/scimitar/engine.rb +63 -0
  64. data/lib/scimitar/version.rb +13 -0
  65. data/spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb +24 -0
  66. data/spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb +30 -0
  67. data/spec/apps/dummy/app/controllers/mock_groups_controller.rb +13 -0
  68. data/spec/apps/dummy/app/controllers/mock_users_controller.rb +13 -0
  69. data/spec/apps/dummy/app/models/mock_group.rb +83 -0
  70. data/spec/apps/dummy/app/models/mock_user.rb +104 -0
  71. data/spec/apps/dummy/config/application.rb +17 -0
  72. data/spec/apps/dummy/config/boot.rb +2 -0
  73. data/spec/apps/dummy/config/environment.rb +2 -0
  74. data/spec/apps/dummy/config/environments/test.rb +15 -0
  75. data/spec/apps/dummy/config/initializers/cookies_serializer.rb +3 -0
  76. data/spec/apps/dummy/config/initializers/scimitar.rb +14 -0
  77. data/spec/apps/dummy/config/initializers/session_store.rb +3 -0
  78. data/spec/apps/dummy/config/routes.rb +24 -0
  79. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +15 -0
  80. data/spec/apps/dummy/db/migrate/20210308020313_create_mock_groups.rb +10 -0
  81. data/spec/apps/dummy/db/migrate/20210308044214_create_join_table_mock_groups_mock_users.rb +8 -0
  82. data/spec/apps/dummy/db/schema.rb +42 -0
  83. data/spec/controllers/scimitar/application_controller_spec.rb +173 -0
  84. data/spec/controllers/scimitar/resource_types_controller_spec.rb +94 -0
  85. data/spec/controllers/scimitar/resources_controller_spec.rb +247 -0
  86. data/spec/controllers/scimitar/schemas_controller_spec.rb +75 -0
  87. data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +22 -0
  88. data/spec/models/scimitar/complex_types/address_spec.rb +19 -0
  89. data/spec/models/scimitar/complex_types/email_spec.rb +23 -0
  90. data/spec/models/scimitar/lists/count_spec.rb +147 -0
  91. data/spec/models/scimitar/lists/query_parser_spec.rb +763 -0
  92. data/spec/models/scimitar/resource_type_spec.rb +21 -0
  93. data/spec/models/scimitar/resources/base_spec.rb +289 -0
  94. data/spec/models/scimitar/resources/base_validation_spec.rb +61 -0
  95. data/spec/models/scimitar/resources/mixin_spec.rb +2127 -0
  96. data/spec/models/scimitar/resources/user_spec.rb +55 -0
  97. data/spec/models/scimitar/schema/attribute_spec.rb +80 -0
  98. data/spec/models/scimitar/schema/base_spec.rb +64 -0
  99. data/spec/models/scimitar/schema/group_spec.rb +87 -0
  100. data/spec/models/scimitar/schema/user_spec.rb +710 -0
  101. data/spec/requests/active_record_backed_resources_controller_spec.rb +569 -0
  102. data/spec/requests/application_controller_spec.rb +49 -0
  103. data/spec/requests/controller_configuration_spec.rb +17 -0
  104. data/spec/requests/engine_spec.rb +20 -0
  105. data/spec/spec_helper.rb +66 -0
  106. metadata +315 -0
@@ -0,0 +1,730 @@
1
+ module Scimitar
2
+ module Lists
3
+
4
+ # Simple SCIM filter support.
5
+ #
6
+ # This is currently an extremely limited query parser supporting only a
7
+ # single "name-operator-value" query, no boolean operations or precedence
8
+ # operators and it assumes "(I)LIKE" and "%" as wildcards in SQL for any
9
+ # operators which require partial match (contains / "co", starts with /
10
+ # "sw", ends with / "ew"). Generic operations don't support "pr" either
11
+ # ('presence').
12
+ #
13
+ # Create an instance, then construct a query appropriate for your storage
14
+ # back-end using #attribute to get the attribute name (in terms of "your
15
+ # data", via your Scimitar::Resources::Mixin-including class implementation
16
+ # of ::scim_queryable_attributes), #operator to get a generic SQL operator
17
+ # such as "=" or "!=" and #parameter to get the value to be found (which
18
+ # you MUST take care to process so as to avoid an SQL injection or similar
19
+ # issues - use escaping suitable for your storage system's query language).
20
+ #
21
+ # * If you don't want to support (I)LIKE just check for it in #parameter's
22
+ # return value; it'll be upper case.
23
+ #
24
+ # Given the likelihood of using ActiveRecord via Rails, there's a higher
25
+ # level and easier method - just create the instance, then call
26
+ # QueryParser#to_activerecord_query to get a given base scope narrowed down
27
+ # to match the filter parameters.
28
+ #
29
+ class QueryParser
30
+
31
+ attr_reader :attribute_map, :rpn
32
+
33
+ # Combined operator precedence.
34
+ #
35
+ OPERATORS = {
36
+ 'pr' => 4,
37
+
38
+ 'eq' => 3,
39
+ 'ne' => 3,
40
+ 'gt' => 3,
41
+ 'ge' => 3,
42
+ 'lt' => 3,
43
+ 'le' => 3,
44
+ 'co' => 3,
45
+ 'sw' => 3,
46
+ 'ew' => 3,
47
+
48
+ 'and' => 2,
49
+ 'or' => 1
50
+ }.freeze
51
+
52
+ # Unary operators.
53
+ #
54
+ UNARY_OPERATORS = Set.new([
55
+ 'pr'
56
+ ]).freeze
57
+
58
+ # Binary operators.
59
+ #
60
+ BINARY_OPERATORS = Set.new(OPERATORS.keys.reject { |op| UNARY_OPERATORS.include?(op) }).freeze
61
+
62
+ # =======================================================================
63
+ # Tokenizing expressions
64
+ # =======================================================================
65
+
66
+ PAREN = /[\(\)]/.freeze
67
+ STR = /"(?:\\"|[^"])*"/.freeze
68
+ OP = /#{OPERATORS.keys.join('|')}/i.freeze
69
+ WORD = /[\w\.]+/.freeze
70
+ SEP = /\s?/.freeze
71
+ NEXT_TOKEN = /\A(#{PAREN}|#{STR}|#{OP}|#{WORD})#{SEP}/.freeze
72
+ IS_OPERATOR = /\A(?:#{OP})\Z/.freeze
73
+
74
+ # Initialise an object.
75
+ #
76
+ # +attribute_map+:: See Scimitar::Resources::Mixin and documentation on
77
+ # implementing ::scim_queryable_attributes; pass that
78
+ # method's return value here.
79
+ #
80
+ def initialize(attribute_map)
81
+ @attribute_map = attribute_map
82
+ end
83
+
84
+ # Parse SCIM filter query into RPN stack
85
+ #
86
+ # +input+:: Input filter string, e.g. 'givenName eq "Briony"'.
87
+ #
88
+ # Returns a "self" for convenience. Call #rpn thereafter to retrieve the
89
+ # parsed RPN stack. For example, given this input:
90
+ #
91
+ # userType eq "Employee" and (emails co "example.com" or emails co "example.org")
92
+ #
93
+ # ...returns a parser object wherein #rpn will yield:
94
+ #
95
+ # [
96
+ # 'userType',
97
+ # '"Employee"',
98
+ # 'eq',
99
+ # 'emails',
100
+ # '"example.com"',
101
+ # 'co',
102
+ # 'emails',
103
+ # '"example.org"',
104
+ # 'co',
105
+ # 'or',
106
+ # 'and'
107
+ # ]
108
+ #
109
+ # Alternatively, call #tree to get an expression tree:
110
+ #
111
+ # [
112
+ # 'and',
113
+ # [
114
+ # 'eq',
115
+ # 'userType',
116
+ # '"Employee"'
117
+ # ],
118
+ # [
119
+ # 'or',
120
+ # [
121
+ # 'co',
122
+ # 'emails',
123
+ # '"example.com"'
124
+ # ],
125
+ # [
126
+ # 'co',
127
+ # 'emails',
128
+ # '"example.org"'
129
+ # ]
130
+ # ]
131
+ # ]
132
+ #
133
+ def parse(input)
134
+ preprocessed_input = flatten_filter(input) rescue input
135
+
136
+ @input = input.clone() # Saved just for error msgs
137
+ @tokens = self.lex(preprocessed_input)
138
+ @rpn = self.parse_expr()
139
+
140
+ self.assert_eos()
141
+ self
142
+ end
143
+
144
+ # Transform the RPN stack into a tree, returning the result. A new tree
145
+ # is created each time, so you can mutate the result if need be.
146
+ #
147
+ # See #parse for more information.
148
+ #
149
+ def tree
150
+ @stack = @rpn.clone()
151
+ self.get_tree()
152
+ end
153
+
154
+ # Having called #parse, call here to generate an ActiveRecord query based
155
+ # on a given starting scope. The scope is used for all 'and' queries and
156
+ # as a basis for any nested 'or' scopes. For example, given this input:
157
+ #
158
+ # userType eq "Employee" and (emails eq "a@b.com" or emails eq "a@b.org")
159
+ #
160
+ # ...and if you passed 'User.active' as a scope, there would be something
161
+ # along these lines sent to ActiveRecord:
162
+ #
163
+ # User.active.where(user_type: 'Employee').and(User.active.where(work_email: 'a@b.com').or(User.active.where(work_email: 'a@b.org')))
164
+ #
165
+ # See query_parser_spec.rb to get an idea for expected SQL based on various
166
+ # kinds of input, especially section "context 'with complex cases' do".
167
+ #
168
+ # +base_scope+:: The starting scope, e.g. User.active.
169
+ #
170
+ # Returns an ActiveRecord::Relation giving an SQL query that is the gem's
171
+ # best attempt at interpreting the SCIM filter string.
172
+ #
173
+ def to_activerecord_query(base_scope)
174
+ return self.to_activerecord_query_backend(
175
+ base_scope: base_scope,
176
+ expression_tree: self.tree()
177
+ )
178
+ end
179
+
180
+ # =======================================================================
181
+ # PRIVATE INSTANCE METHODS
182
+ # =======================================================================
183
+ #
184
+ private
185
+
186
+ def parse_expr
187
+ ast = []
188
+ expect_op = false
189
+
190
+ while !self.eos? && self.peek() != ')'
191
+ expect_op && self.assert_op() || self.assert_not_op()
192
+
193
+ ast.push(self.start_group? ? self.parse_group() : self.pop())
194
+
195
+ unless ! ast.last.is_a?(String) || UNARY_OPERATORS.include?(ast.last.downcase)
196
+ expect_op ^= true
197
+ end
198
+ end
199
+
200
+ self.to_rpn(ast)
201
+ end
202
+
203
+ def parse_group
204
+ # pop '(' token
205
+ self.pop()
206
+
207
+ ast = self.parse_expr()
208
+
209
+ # pop ')' token
210
+ self.assert_close() && self.pop()
211
+
212
+ ast
213
+ end
214
+
215
+ # Split input into tokens. Returns an array of strings.
216
+ #
217
+ # +input+:: String to parse.
218
+ #
219
+ def lex(input)
220
+ input = input.clone
221
+ tokens = []
222
+
223
+ until input.empty? do
224
+ input.sub!(NEXT_TOKEN, '') || fail(Scimitar::FilterError, "Can't lex input here '#{input}'")
225
+
226
+ tokens.push($1)
227
+ end
228
+ tokens
229
+ end
230
+
231
+ # Turn parsed tokens into an RPN stack
232
+ #
233
+ # See also http://en.wikipedia.org/wiki/Shunting_yard_algorithm
234
+ #
235
+ # +ast+:: Array of parsed tokens (see e.g. #parse_expr).
236
+ #
237
+ def to_rpn(ast)
238
+ out = []
239
+ ops = []
240
+
241
+ out.push(ast.shift) unless ast.empty?
242
+
243
+ until ast.empty? do
244
+ op = ast.shift
245
+ precedence = OPERATORS[op&.downcase] || fail(Scimitar::FilterError, "Unknown operator '#{op}'")
246
+
247
+ until ops.empty? do
248
+ break if precedence > OPERATORS[ops.first&.downcase]
249
+ out.push(ops.shift)
250
+ end
251
+
252
+ ops.unshift(op)
253
+ out.push(ast.shift) unless UNARY_OPERATORS.include?(op&.downcase)
254
+ end
255
+ (out.concat(ops)).flatten
256
+ end
257
+
258
+ # Transform RPN stack into a tree structure. A new copy of the tree is
259
+ # returned each time, so you can mutate the result if need be.
260
+ #
261
+ def get_tree
262
+ tree = []
263
+ unless @stack.empty?
264
+ op = tree[0] = @stack.pop()
265
+ tree[1] = OPERATORS[@stack.last&.downcase] ? self.get_tree() : @stack.pop()
266
+
267
+ unless UNARY_OPERATORS.include?(op&.downcase)
268
+ tree.insert(1, (OPERATORS[@stack.last&.downcase] ? self.get_tree() : @stack.pop()))
269
+ end
270
+ end
271
+ tree
272
+ end
273
+
274
+ # =====================================================================
275
+ # Flattening
276
+ # =====================================================================
277
+
278
+ # A depressingly heavyweight method that attempts to partly rationalise
279
+ # some of the simpler cases of filters-in-filters by realising that the
280
+ # expression can be done in two ways.
281
+ #
282
+ # https://tools.ietf.org/html/rfc7644#page-23 includes these examples:
283
+ #
284
+ # filter=userType eq "Employee" and (emails.type eq "work")
285
+ # filter=userType eq "Employee" and emails[type eq "work" and value co "@example.com"]
286
+ #
287
+ # Ignoring the extra 'and', we can see that using either a nested
288
+ # filter-like path *or* the dotted notation are equivalent here. So, if
289
+ # we were to step along the string looking for an unescaped "[" at the
290
+ # start of an inner filter, we could extract attributes therein and use
291
+ # the part before the "[" as a prefix - "emails[type" to "emails.type",
292
+ # with similar substitutions therein.
293
+ #
294
+ # This method tries to flatten things thus. It throws exceptions if any
295
+ # problems arise at all. Some limitations:
296
+ #
297
+ # * Inner filters with further complex filters inside will not work.
298
+ # * Spaces immediately after an opening "[" will break the flattener.
299
+ # * 'not' can only be used in the context of 'and not' / 'or not'; it
300
+ # isn't supported stand-alone at the start of expressions (e.g.
301
+ # 'not userType eq "Employee')
302
+ #
303
+ # Examples:
304
+ #
305
+ # <- userType eq "Employee" and emails[type eq "work" and value co "@example.com"]
306
+ # => userType eq "Employee" and emails.type eq "work" and emails.value co "@example.com"
307
+ #
308
+ # <- emails[type eq "work" and value co "@example.com"] or ims[type eq "xmpp" and value co "@foo.com"]
309
+ # => emails.type eq "work" and emails.value co "@example.com" or ims.type eq "xmpp" and ims.value co "@foo.com"
310
+ #
311
+ # <- userType eq "Employee" or userName pr and emails[type eq "with spaces" and value co "@example.com"]
312
+ # => userType eq "Employee" or userName pr and emails.type eq "with spaces" and emails.value co "@example.com"
313
+ #
314
+ # <- userType ne "Employee" and not (emails[value co "example.com" or (value co "example.org")]) and userName="foo"
315
+ # => userType ne "Employee" and not (emails.value co "example.com" or (emails.value co "example.org")) and userName="foo"
316
+ #
317
+ # +filter+:: Input filter string. Returns a possibly modified String,
318
+ # with the hopefully equivalent but flattened filter inside.
319
+ #
320
+ def flatten_filter(filter)
321
+ rewritten = []
322
+ components = filter.gsub(/\s+\]/, ']').split(' ')
323
+ expecting_attribute = true
324
+ expecting_closing_bracket = false
325
+ attribute_prefix = nil
326
+ expecting_operator = false
327
+ expecting_value = false
328
+ expecting_closing_quote = false
329
+ expecting_logic_word = false
330
+ skip_next_component = false
331
+
332
+ components.each.with_index do | component, index |
333
+ if skip_next_component == true
334
+ skip_next_component = false
335
+ next
336
+ end
337
+
338
+ downcased = component.downcase.strip
339
+
340
+ if (expecting_attribute)
341
+ if downcased.match?(/[^\\]\[/) # Not backslash then literal '['
342
+ attribute_prefix = component.match(/(.*?[^\\])\[/ )[1] # Everything before no-backslash-then-literal (unescaped) '['
343
+ first_attribute_inside = component.match( /[^\\]\[(.*)/)[1] # Everything after no-backslash-then-literal (unescaped) '['
344
+ opening_paren = '(' if attribute_prefix.start_with?('(')
345
+ rewritten << "#{opening_paren}#{apply_attribute_prefix(attribute_prefix, first_attribute_inside)}"
346
+ expecting_closing_bracket = true
347
+ else # No inner filter component being started, but might be inside one with a prefix set
348
+ rewritten << apply_attribute_prefix(attribute_prefix, component)
349
+ end
350
+ expecting_attribute = false
351
+ expecting_operator = true
352
+
353
+ elsif (expecting_operator)
354
+ rewritten << component
355
+ if BINARY_OPERATORS.include?(downcased)
356
+ expecting_operator = false
357
+ expecting_value = true
358
+ elsif UNARY_OPERATORS.include?(downcased)
359
+ expecting_operator = false
360
+ expecting_logic_word = true
361
+ else
362
+ raise 'Expected operator'
363
+ end
364
+
365
+ elsif (expecting_value)
366
+ matches = downcased.match(/([^\\])\]/) # Contains no-backslash-then-literal (unescaped) ']'
367
+ unless matches.nil? # Contains no-backslash-then-literal (unescaped) ']'
368
+ character_before_closing_bracket = matches[1]
369
+ component.gsub!(/[^\\]\]/, character_before_closing_bracket)
370
+
371
+ if expecting_closing_bracket
372
+ attribute_prefix = nil
373
+ expecting_closing_bracket = false
374
+ else
375
+ raise 'Unexpected closing "]"'
376
+ end
377
+ end
378
+
379
+ rewritten << component
380
+
381
+ if downcased.start_with?('"')
382
+ expecting_closing_quote = true
383
+ 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
385
+ expecting_value = false
386
+ expecting_logic_word = true
387
+ end
388
+
389
+ if expecting_closing_quote
390
+ if downcased.match?(/[^\\]\"/) # Contains no-backslash-then-literal (unescaped) '"'
391
+ expecting_closing_quote = false
392
+ expecting_value = false
393
+ expecting_logic_word = true
394
+ end
395
+ end
396
+
397
+ elsif (expecting_logic_word)
398
+ if downcased == 'and' || downcased == 'or'
399
+ rewritten << component
400
+ next_downcased_component = components[index + 1].downcase.strip
401
+ if next_downcased_component == 'not' # Special case "and not" / "or not"
402
+ skip_next_component = true
403
+ rewritten << 'not'
404
+ end
405
+ expecting_logic_word = false
406
+ expecting_attribute = true
407
+ else
408
+ raise 'Expected "and" or "or"'
409
+ end
410
+ end
411
+ end
412
+
413
+ return rewritten.join(' ')
414
+ end
415
+
416
+ # Service method to DRY up #flatten_filter a little. Applies a prefix
417
+ # to a component, but is careful with opening parentheses.
418
+ #
419
+ # +attribute_prefix+:: Prefix from before a "[", which may include an
420
+ # opening "(" itself.
421
+ #
422
+ # +component+:: Component attribute to receive the prefix, which
423
+ # may also include an opening "(" itself.
424
+ #
425
+ # The result will be "prefix.component", with a "(" at the start if
426
+ # the *component* had an opening paren. If the prefix includes one, the
427
+ # caller must deal with it as only the caller knows if this application
428
+ # of prefix is being done for the first attribute within an inner
429
+ # filter (in which case a wrapping opening paren should be included) or
430
+ # subsequent attributes inside that filter (in which case the original
431
+ # wrapping opening paren should not be repeated).
432
+ #
433
+ def apply_attribute_prefix(attribute_prefix, component)
434
+ if attribute_prefix.nil?
435
+ component
436
+ else
437
+ attribute_prefix = attribute_prefix[1..-1] if attribute_prefix.start_with?('(')
438
+ if component.start_with?('(')
439
+ "(#{attribute_prefix}.#{component[1..-1]}"
440
+ else
441
+ "#{attribute_prefix}.#{component}"
442
+ end
443
+ end
444
+ end
445
+
446
+ # =====================================================================
447
+ # Token sugar methods
448
+ # =====================================================================
449
+
450
+ def peek
451
+ @tokens.first()
452
+ end
453
+
454
+ def pop
455
+ @tokens.shift()
456
+ end
457
+
458
+ def eos?
459
+ @tokens.empty?
460
+ end
461
+
462
+ def start_group?
463
+ self.peek() == '('
464
+ end
465
+
466
+ def peek_operator
467
+ !self.eos? && self.peek().match(IS_OPERATOR)
468
+ end
469
+
470
+ # =====================================================================
471
+ # Error handling
472
+ # =====================================================================
473
+
474
+ def parse_error(msg)
475
+ raise Scimitar::FilterError.new("#{sprintf(msg, *@tokens, 'EOS')}.\nInput: '#{@input}'\n")
476
+ end
477
+
478
+ def assert_op
479
+ return true if self.peek_operator()
480
+ self.parse_error("Unexpected token '%s'. Expected operator")
481
+ end
482
+
483
+ def assert_not_op
484
+ return true unless self.peek_operator()
485
+ self.parse_error("Unexpected operator '%s'")
486
+ end
487
+
488
+ def assert_close
489
+ return true if self.peek() == ')'
490
+ self.parse_error("Unexpected token '%s'. Expected ')'")
491
+ end
492
+
493
+ def assert_eos
494
+ return true if self.eos?
495
+ self.parse_error("Unexpected token '%s'. Expected EOS")
496
+ end
497
+
498
+ # =====================================================================
499
+ # ActiveRecord query support
500
+ # =====================================================================
501
+
502
+ # Recursively process an expression tree. Calls itself with nested tree
503
+ # fragments. Each inner expression fragment calculates on the given
504
+ # base scope, with aggregration at each level into a wider query using
505
+ # AND or OR depending on the expression tree contents.
506
+ #
507
+ # +base_scope+:: Base scope (ActiveRecord::Relation, e.g. User.all
508
+ # - neverchanges during recursion).
509
+ #
510
+ # +expression_tree+:: Top-level expression tree or fragments inside if
511
+ # self-calling recursively.
512
+ #
513
+ def to_activerecord_query_backend(base_scope:, expression_tree:)
514
+ combining_method = :and
515
+ combining_yet = false
516
+ query = base_scope
517
+
518
+ first_item = expression_tree.first
519
+ first_item = first_item.downcase if first_item.is_a?(String)
520
+
521
+ if first_item == 'or'
522
+ combining_method = :or
523
+ expression_tree.shift()
524
+ elsif first_item == 'and'
525
+ expression_tree.shift()
526
+ elsif ! first_item.is_a?(Array) # Simple query; entire tree is just presence tuple or expression triple
527
+ raise Scimitar::FilterError unless expression_tree.size == 2 || expression_tree.size == 3
528
+ return apply_scim_filter( # NOTE EARLY EXIT
529
+ base_scope: query,
530
+ scim_attribute: expression_tree[1],
531
+ scim_operator: expression_tree[0]&.downcase,
532
+ scim_parameter: expression_tree[2],
533
+ case_sensitive: false
534
+ )
535
+ end
536
+
537
+ expression_tree.each do | entry |
538
+ raise Scimitar::FilterError unless entry.is_a?(Array)
539
+
540
+ first_sub_item = entry.first
541
+ first_sub_item = first_sub_item.downcase if first_sub_item.is_a?(String)
542
+ nested = first_sub_item.is_a?(Array) || first_sub_item == 'and' || first_sub_item == 'or'
543
+
544
+ if nested
545
+ query_component = to_activerecord_query_backend(
546
+ base_scope: base_scope,
547
+ expression_tree: entry
548
+ )
549
+ else
550
+ raise Scimitar::FilterError unless entry.size == 2 || entry.size == 3
551
+ query_component = apply_scim_filter(
552
+ base_scope: base_scope,
553
+ scim_attribute: entry[1],
554
+ scim_operator: entry[0]&.downcase,
555
+ scim_parameter: entry[2],
556
+ case_sensitive: false
557
+ )
558
+ end
559
+
560
+ # ActiveRecord quirk: User.and(User.where...) produces useful SQL
561
+ # but User.or(User.where...) just ignores everything inside 'or';
562
+ # so, make sure we only use a combination method once actually
563
+ # combining things - not for the very first query component.
564
+ #
565
+ if combining_yet
566
+ query = query.send(combining_method, query_component)
567
+ else
568
+ query = query.and(query_component)
569
+ combining_yet = true
570
+ end
571
+ end
572
+
573
+ return query
574
+ end
575
+
576
+ # Apply a filter to a given base scope. Mandatory named parameters:
577
+ #
578
+ # +base_scope+:: Base scope (ActiveRecord::Relation, e.g. User.all)
579
+ # +scim_attribute+:: SCIM domain attribute, e.g. 'familyName'
580
+ # +scim_operator+:: SCIM operator, e.g. 'eq', 'co', 'pr'
581
+ # +scim_parameter+:: Parameter to match, or +nil+ for operator 'pr'
582
+ # +case_sensitive+:: Boolean; see notes below on case sensitivity.
583
+ #
584
+ # Case sensitivity in databases is tricky. If you wanted case sensitive
585
+ # behaviour then it might need configuration in your engine. Internally
586
+ # Scimitar uses AREL operations to try and get appropriate behaviour so
587
+ # if your database adapter is doing the right thing there should be the
588
+ # right outcome - even so, check your database adapter documentation to
589
+ # be sure.
590
+ #
591
+ def apply_scim_filter(
592
+ base_scope:,
593
+ scim_attribute:,
594
+ scim_operator:,
595
+ scim_parameter:,
596
+ case_sensitive:
597
+ )
598
+ scim_operator = scim_operator.downcase
599
+ query = base_scope
600
+ arel_table = base_scope.model.arel_table
601
+ column_names = self.activerecord_columns(scim_attribute)
602
+ value = self.activerecord_parameter(scim_parameter)
603
+ value_for_like = self.sql_modified_value(scim_operator, value)
604
+ all_supported = column_names.all? { | column_name | base_scope.model.column_names.include?(column_name.to_s) }
605
+
606
+ raise Scimitar::FilterError unless all_supported
607
+
608
+ column_names.each.with_index do | column_name, index |
609
+ arel_column = arel_table[column_name]
610
+ arel_operation = case scim_operator
611
+ when 'eq'
612
+ if case_sensitive
613
+ arel_column.eq(value)
614
+ else
615
+ arel_column.matches(value_for_like, nil, false) # false -> case insensitive
616
+ end
617
+ when 'ne'
618
+ if case_sensitive
619
+ arel_column.not_eq(value)
620
+ else
621
+ arel_column.does_not_match(value_for_like, nil, false) # false -> case insensitive
622
+ end
623
+ when 'gt'
624
+ arel_column.gt(value)
625
+ when 'ge'
626
+ arel_column.gteq(value)
627
+ when 'lt'
628
+ arel_column.lt(value)
629
+ when 'le'
630
+ arel_column.lteq(value)
631
+ when 'co', 'sw', 'ew'
632
+ arel_column.matches(value_for_like, nil, case_sensitive)
633
+ when 'pr'
634
+ arel_table.grouping(arel_column.not_eq_all(['', nil]))
635
+ else
636
+ raise Scimitar::FilterError
637
+ end
638
+
639
+ if index == 0
640
+ query = base_scope.where(arel_operation)
641
+ else
642
+ query = query.or(base_scope.where(arel_operation))
643
+ end
644
+ end
645
+
646
+ return query
647
+ end
648
+
649
+ # Returns the mapped-to-your-domain column name(s) that a filter string
650
+ # is operating upon, in an Array. If empty, the attribute is to be
651
+ # ignored. Raises an exception if entirey unmapped (thus unsupported).
652
+ #
653
+ # Note plural - the return value is always an array any of which should
654
+ # be used (implicit 'OR').
655
+ #
656
+ # +scim_attribute+:: SCIM attribute from a filter string.
657
+ #
658
+ def activerecord_columns(scim_attribute)
659
+ raise Scimitar::FilterError if scim_attribute.blank?
660
+
661
+ mapped_attribute = self.attribute_map()[scim_attribute]
662
+ raise Scimitar::FilterError if mapped_attribute.blank?
663
+
664
+ if mapped_attribute[:ignore]
665
+ return []
666
+ elsif mapped_attribute[:column]
667
+ return [mapped_attribute[:column]]
668
+ elsif mapped_attribute[:columns]
669
+ return mapped_attribute[:columns]
670
+ else
671
+ raise "Malformed scim_queryable_attributes entry for #{scim_attribute.inspect}"
672
+ end
673
+ end
674
+
675
+ # Return the parameter that you're looking for, from a filter string.
676
+ # This might be blank, e.g. for "pr" (presence), but is never +nil+. Use
677
+ # this to construct your storage-system-specific search string but be
678
+ # sure to escape it, where necessary, for any special characters (e.g.
679
+ # to prevent SQL injection attacks or accidentally-wildcarded searches).
680
+ #
681
+ # +scim_operator+:: SCIM parameter from a filter string. Even if given
682
+ # +nil+ here, would always return an empty string;
683
+ # all blank inputs yield this.
684
+ #
685
+ def activerecord_parameter(scim_parameter)
686
+ if scim_parameter.blank?
687
+ return ''
688
+ elsif scim_parameter.start_with?('"') && scim_parameter.end_with?('"')
689
+ return scim_parameter[1..-2]
690
+ else
691
+ return scim_parameter
692
+ end
693
+ end
694
+
695
+ # Pass a SCIM operation and a value from e.g. #activerecord_parameter.
696
+ # Returns a safe-for-"LIKE" string with potential internal wildcards
697
+ # escaped, along with actual wildcards added for "co" (contains), "sw"
698
+ # (starts with) or "ew" (ends with) operators.
699
+ #
700
+ # Escapes values for "eq" and "ne" operations on assumption of a LIKE
701
+ # style query by the caller - the caller should use the modified value
702
+ # only if that is indeed their intended use case. If just using e.g.
703
+ # SQL "=" or "!=", use the #activerecord_parameter (or other original
704
+ # 'raw' value) instead.
705
+ #
706
+ # For other operators, just returns the given value directly.
707
+ #
708
+ # +scim_operator+:: The SCIM operator, lower case.
709
+ # +value+:: Parameter (value) to potentially escape or modify.
710
+ #
711
+ def sql_modified_value(scim_operator, value)
712
+ safe_for_LIKE_value = ActiveRecord::Base.sanitize_sql_like(value) if value.present?
713
+
714
+ case scim_operator
715
+ when 'eq', 'ne'
716
+ safe_for_LIKE_value
717
+ when 'co'
718
+ "%#{safe_for_LIKE_value}%"
719
+ when 'sw'
720
+ "#{safe_for_LIKE_value}%"
721
+ when 'ew'
722
+ "%#{safe_for_LIKE_value}"
723
+ else
724
+ value
725
+ end
726
+ end
727
+
728
+ end
729
+ end
730
+ end