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.
- checksums.yaml +7 -0
- data/Rakefile +16 -0
- data/app/controllers/scimitar/active_record_backed_resources_controller.rb +180 -0
- data/app/controllers/scimitar/application_controller.rb +129 -0
- data/app/controllers/scimitar/resource_types_controller.rb +28 -0
- data/app/controllers/scimitar/resources_controller.rb +203 -0
- data/app/controllers/scimitar/schemas_controller.rb +16 -0
- data/app/controllers/scimitar/service_provider_configurations_controller.rb +8 -0
- data/app/models/scimitar/authentication_error.rb +9 -0
- data/app/models/scimitar/authentication_scheme.rb +18 -0
- data/app/models/scimitar/bulk.rb +8 -0
- data/app/models/scimitar/complex_types/address.rb +18 -0
- data/app/models/scimitar/complex_types/base.rb +41 -0
- data/app/models/scimitar/complex_types/email.rb +12 -0
- data/app/models/scimitar/complex_types/entitlement.rb +12 -0
- data/app/models/scimitar/complex_types/ims.rb +12 -0
- data/app/models/scimitar/complex_types/name.rb +12 -0
- data/app/models/scimitar/complex_types/phone_number.rb +12 -0
- data/app/models/scimitar/complex_types/photo.rb +12 -0
- data/app/models/scimitar/complex_types/reference_group.rb +12 -0
- data/app/models/scimitar/complex_types/reference_member.rb +12 -0
- data/app/models/scimitar/complex_types/role.rb +12 -0
- data/app/models/scimitar/complex_types/x509_certificate.rb +12 -0
- data/app/models/scimitar/engine_configuration.rb +24 -0
- data/app/models/scimitar/error_response.rb +20 -0
- data/app/models/scimitar/errors.rb +14 -0
- data/app/models/scimitar/filter.rb +11 -0
- data/app/models/scimitar/filter_error.rb +22 -0
- data/app/models/scimitar/invalid_syntax_error.rb +9 -0
- data/app/models/scimitar/lists/count.rb +64 -0
- data/app/models/scimitar/lists/query_parser.rb +730 -0
- data/app/models/scimitar/meta.rb +7 -0
- data/app/models/scimitar/not_found_error.rb +10 -0
- data/app/models/scimitar/resource_invalid_error.rb +9 -0
- data/app/models/scimitar/resource_type.rb +29 -0
- data/app/models/scimitar/resources/base.rb +159 -0
- data/app/models/scimitar/resources/group.rb +13 -0
- data/app/models/scimitar/resources/mixin.rb +964 -0
- data/app/models/scimitar/resources/user.rb +13 -0
- data/app/models/scimitar/schema/address.rb +24 -0
- data/app/models/scimitar/schema/attribute.rb +123 -0
- data/app/models/scimitar/schema/base.rb +86 -0
- data/app/models/scimitar/schema/derived_attributes.rb +24 -0
- data/app/models/scimitar/schema/email.rb +10 -0
- data/app/models/scimitar/schema/entitlement.rb +10 -0
- data/app/models/scimitar/schema/group.rb +27 -0
- data/app/models/scimitar/schema/ims.rb +10 -0
- data/app/models/scimitar/schema/name.rb +20 -0
- data/app/models/scimitar/schema/phone_number.rb +10 -0
- data/app/models/scimitar/schema/photo.rb +10 -0
- data/app/models/scimitar/schema/reference_group.rb +23 -0
- data/app/models/scimitar/schema/reference_member.rb +21 -0
- data/app/models/scimitar/schema/role.rb +10 -0
- data/app/models/scimitar/schema/user.rb +52 -0
- data/app/models/scimitar/schema/vdtp.rb +18 -0
- data/app/models/scimitar/schema/x509_certificate.rb +22 -0
- data/app/models/scimitar/service_provider_configuration.rb +49 -0
- data/app/models/scimitar/supportable.rb +14 -0
- data/app/views/layouts/scimitar/application.html.erb +14 -0
- data/config/initializers/scimitar.rb +82 -0
- data/config/routes.rb +6 -0
- data/lib/scimitar.rb +23 -0
- data/lib/scimitar/engine.rb +63 -0
- data/lib/scimitar/version.rb +13 -0
- data/spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb +24 -0
- data/spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb +30 -0
- data/spec/apps/dummy/app/controllers/mock_groups_controller.rb +13 -0
- data/spec/apps/dummy/app/controllers/mock_users_controller.rb +13 -0
- data/spec/apps/dummy/app/models/mock_group.rb +83 -0
- data/spec/apps/dummy/app/models/mock_user.rb +104 -0
- data/spec/apps/dummy/config/application.rb +17 -0
- data/spec/apps/dummy/config/boot.rb +2 -0
- data/spec/apps/dummy/config/environment.rb +2 -0
- data/spec/apps/dummy/config/environments/test.rb +15 -0
- data/spec/apps/dummy/config/initializers/cookies_serializer.rb +3 -0
- data/spec/apps/dummy/config/initializers/scimitar.rb +14 -0
- data/spec/apps/dummy/config/initializers/session_store.rb +3 -0
- data/spec/apps/dummy/config/routes.rb +24 -0
- data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +15 -0
- data/spec/apps/dummy/db/migrate/20210308020313_create_mock_groups.rb +10 -0
- data/spec/apps/dummy/db/migrate/20210308044214_create_join_table_mock_groups_mock_users.rb +8 -0
- data/spec/apps/dummy/db/schema.rb +42 -0
- data/spec/controllers/scimitar/application_controller_spec.rb +173 -0
- data/spec/controllers/scimitar/resource_types_controller_spec.rb +94 -0
- data/spec/controllers/scimitar/resources_controller_spec.rb +247 -0
- data/spec/controllers/scimitar/schemas_controller_spec.rb +75 -0
- data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +22 -0
- data/spec/models/scimitar/complex_types/address_spec.rb +19 -0
- data/spec/models/scimitar/complex_types/email_spec.rb +23 -0
- data/spec/models/scimitar/lists/count_spec.rb +147 -0
- data/spec/models/scimitar/lists/query_parser_spec.rb +763 -0
- data/spec/models/scimitar/resource_type_spec.rb +21 -0
- data/spec/models/scimitar/resources/base_spec.rb +289 -0
- data/spec/models/scimitar/resources/base_validation_spec.rb +61 -0
- data/spec/models/scimitar/resources/mixin_spec.rb +2127 -0
- data/spec/models/scimitar/resources/user_spec.rb +55 -0
- data/spec/models/scimitar/schema/attribute_spec.rb +80 -0
- data/spec/models/scimitar/schema/base_spec.rb +64 -0
- data/spec/models/scimitar/schema/group_spec.rb +87 -0
- data/spec/models/scimitar/schema/user_spec.rb +710 -0
- data/spec/requests/active_record_backed_resources_controller_spec.rb +569 -0
- data/spec/requests/application_controller_spec.rb +49 -0
- data/spec/requests/controller_configuration_spec.rb +17 -0
- data/spec/requests/engine_spec.rb +20 -0
- data/spec/spec_helper.rb +66 -0
- 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
|