scimitar 1.0.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 (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