permittable 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +155 -0
- data/README.md +361 -33
- data/lib/permittable/audit.rb +268 -0
- data/lib/permittable/authored_values.rb +63 -0
- data/lib/permittable/column_guard.rb +133 -6
- data/lib/permittable/contract.rb +11 -2
- data/lib/permittable/error_envelope.rb +79 -4
- data/lib/permittable/field_group.rb +72 -0
- data/lib/permittable/generator.rb +822 -51
- data/lib/permittable/json_schema/ecma_pattern.rb +248 -0
- data/lib/permittable/json_schema.rb +252 -47
- data/lib/permittable/open_api.rb +247 -42
- data/lib/permittable/railtie.rb +1 -0
- data/lib/permittable/rspec.rb +451 -25
- data/lib/permittable/tasks/audit.rake +34 -0
- data/lib/permittable/tasks/generate.rake +3 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +985 -116
- metadata +9 -4
data/lib/permittable/rspec.rb
CHANGED
|
@@ -14,6 +14,14 @@ module Permittable
|
|
|
14
14
|
# expect(UsersController).to permit_param("address.zip").as(:string).optional
|
|
15
15
|
# expect(UsersController).not_to permit_param(:admin).for_action(:create)
|
|
16
16
|
#
|
|
17
|
+
# The negated form asserts that the contract does not declare the param,
|
|
18
|
+
# and takes no qualifiers: `not_to permit_param(:admin).required` would
|
|
19
|
+
# pass both when :admin is undeclared and when it is declared optional,
|
|
20
|
+
# which is a false positive in exactly the assertion most likely to guard
|
|
21
|
+
# a security property. It raises instead, and names the positive form to
|
|
22
|
+
# write. It reads the contract, not the runtime mode: under a rule in
|
|
23
|
+
# monitor mode, permitted_params hands back undeclared keys regardless.
|
|
24
|
+
#
|
|
17
25
|
# `for_action` picks the rule exactly like a request would
|
|
18
26
|
# (`permit_rule_for`); it may be omitted only when the controller declares
|
|
19
27
|
# a single contract, so an ambiguous expectation fails loudly instead of
|
|
@@ -23,14 +31,35 @@ module Permittable
|
|
|
23
31
|
PermitParamMatcher.new(path)
|
|
24
32
|
end
|
|
25
33
|
|
|
34
|
+
# What the contract DOES with a payload, as opposed to what it declares.
|
|
35
|
+
#
|
|
36
|
+
# expect(UsersController).to accept_params(user: { name: "Jo" })
|
|
37
|
+
# .for_action(:create).returning("name" => "Jo", "plan" => "free")
|
|
38
|
+
#
|
|
39
|
+
# expect(UsersController).to reject_params(user: {})
|
|
40
|
+
# .for_action(:create).with_violation("user.name", :missing)
|
|
41
|
+
#
|
|
42
|
+
# No request is dispatched: the rule is run against the payload directly,
|
|
43
|
+
# so these work on a controller class, a controller instance, or a
|
|
44
|
+
# standalone Permittable::Contract.
|
|
45
|
+
def accept_params(params)
|
|
46
|
+
ParamsBehaviourMatcher.new(params, expect_accepted: true)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reject_params(params)
|
|
50
|
+
ParamsBehaviourMatcher.new(params, expect_accepted: false)
|
|
51
|
+
end
|
|
52
|
+
|
|
26
53
|
class PermitParamMatcher
|
|
27
54
|
OPTION_LABELS = { in: "in:", format: "format:", length: "length:", default: "default:" }.freeze
|
|
55
|
+
# One path segment: a name plus any [n] indexes (the runtime's form).
|
|
56
|
+
SEGMENT = /\A([^\[\]]+)((?:\[\d+\])*)\z/
|
|
28
57
|
|
|
29
58
|
def initialize(path)
|
|
30
59
|
@path = path.to_s
|
|
60
|
+
@segments = path_segments!(@path)
|
|
31
61
|
@action = nil
|
|
32
62
|
@expected = {}
|
|
33
|
-
@mismatches = []
|
|
34
63
|
end
|
|
35
64
|
|
|
36
65
|
# -- chains -----------------------------------------------------------
|
|
@@ -100,34 +129,54 @@ module Permittable
|
|
|
100
129
|
|
|
101
130
|
def matches?(subject)
|
|
102
131
|
@subject = resolve_subject(subject)
|
|
103
|
-
|
|
104
|
-
|
|
132
|
+
case locate
|
|
133
|
+
when :declared then @mismatches = collect_mismatches(@field)
|
|
134
|
+
when :opaque then @mismatches = @expected.empty? ? [] : [opaque_qualifier_mismatch]
|
|
135
|
+
else return false
|
|
136
|
+
end
|
|
137
|
+
@mismatches.empty?
|
|
138
|
+
end
|
|
105
139
|
|
|
106
|
-
|
|
107
|
-
|
|
140
|
+
# Negation is only unambiguous without qualifiers ("not declared"),
|
|
141
|
+
# and only meaningful against a rule that exists: a mistyped
|
|
142
|
+
# `for_action(:craete)` with no catch-all rule resolves to no rule,
|
|
143
|
+
# which declares nothing, so a lenient not_to would pass for any param
|
|
144
|
+
# whatsoever. (With a catch-all it resolves there, as a request
|
|
145
|
+
# would, and is checked against that rule.) The subject is
|
|
146
|
+
# resolved first so a wrong subject is the error reported.
|
|
147
|
+
def does_not_match?(subject) # rubocop:disable Naming/PredicatePrefix -- the RSpec protocol name
|
|
148
|
+
@subject = resolve_subject(subject)
|
|
149
|
+
raise ArgumentError, negated_qualifier_message unless @expected.empty?
|
|
108
150
|
|
|
109
|
-
|
|
110
|
-
@mismatches.empty?
|
|
151
|
+
%i[undeclared too_deep].include?(locate)
|
|
111
152
|
end
|
|
112
153
|
|
|
113
154
|
def failure_message
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
155
|
+
subject = "expected #{subject_name} to permit #{path_label}#{action_label}, but"
|
|
156
|
+
case @status
|
|
157
|
+
when :no_rule then "#{subject} it #{@problem}"
|
|
158
|
+
when :root_prefixed then "#{subject} #{root_prefix_hint}"
|
|
159
|
+
when :too_deep
|
|
160
|
+
"#{subject} it is deeper than the opaque :json field #{label_for(@opaque[:path])} allows " \
|
|
161
|
+
"(max_depth: #{@opaque[:field][:max_depth]})"
|
|
162
|
+
when :undeclared
|
|
163
|
+
"#{subject} it is not declared (declared: #{(@missing_among || []).map { |f| f[:name] }.join(', ')})"
|
|
164
|
+
else "#{subject}:\n #{@mismatches.join("\n ")}"
|
|
120
165
|
end
|
|
121
|
-
|
|
122
|
-
"expected #{subject_name} to permit #{path_label}#{action_label}, but:\n #{@mismatches.join("\n ")}"
|
|
123
166
|
end
|
|
124
167
|
|
|
125
168
|
def failure_message_when_negated
|
|
126
|
-
"expected #{subject_name} not to permit #{path_label}#{action_label}, but
|
|
169
|
+
subject = "expected #{subject_name} not to permit #{path_label}#{action_label}, but"
|
|
170
|
+
case @status
|
|
171
|
+
when :no_rule then "#{subject} it #{@problem}, so there is no rule to check the param against"
|
|
172
|
+
when :opaque
|
|
173
|
+
"#{subject} it is inside the opaque :json field #{label_for(@opaque[:path])}, which lets any nested key through"
|
|
174
|
+
when :root_prefixed then "#{subject} #{root_prefix_hint} — which the contract lets through"
|
|
175
|
+
else "#{subject} the contract declares it"
|
|
176
|
+
end
|
|
127
177
|
end
|
|
128
178
|
|
|
129
179
|
def description
|
|
130
|
-
descriptors = @expected.filter_map { |key, value| describe_check(key, value) }
|
|
131
180
|
label = "permit #{path_label}"
|
|
132
181
|
label += " (for ##{@action})" if @action
|
|
133
182
|
label += " #{descriptors.join(', ')}" unless descriptors.empty?
|
|
@@ -150,6 +199,27 @@ module Permittable
|
|
|
150
199
|
raise ArgumentError, "#{LABEL}: the subject of permit_param must include Permittable (got #{subject.inspect})"
|
|
151
200
|
end
|
|
152
201
|
|
|
202
|
+
# The one lookup both directions share. Sets @status to :no_rule,
|
|
203
|
+
# :declared, :opaque (the path runs into a :json field, which accepts
|
|
204
|
+
# any nested key without declaring it), :too_deep (it runs into one
|
|
205
|
+
# deeper than its max_depth: allows), :root_prefixed (the path starts
|
|
206
|
+
# with the rule's root: and resolves without it), or :undeclared.
|
|
207
|
+
# Every per-run ivar is reset first: a matcher object can be reused
|
|
208
|
+
# on another subject, and must not answer from the previous run.
|
|
209
|
+
def locate
|
|
210
|
+
@rule = @field = @opaque = @problem = @missing_among = nil
|
|
211
|
+
@mismatches = []
|
|
212
|
+
@rule = resolve_rule(@subject)
|
|
213
|
+
return @status = :no_rule unless @rule
|
|
214
|
+
|
|
215
|
+
@field = resolve_field(@rule[:fields], @segments)
|
|
216
|
+
@status = if @field then :declared
|
|
217
|
+
elsif @opaque then opaque_within_depth? ? :opaque : :too_deep
|
|
218
|
+
elsif root_prefixed? then :root_prefixed
|
|
219
|
+
else :undeclared
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
153
223
|
def resolve_rule(subject)
|
|
154
224
|
return resolve_rule_for_action(subject) if @action
|
|
155
225
|
|
|
@@ -173,17 +243,78 @@ module Permittable
|
|
|
173
243
|
end
|
|
174
244
|
|
|
175
245
|
# Walks a dotted path through nested blocks and array-of-hash blocks
|
|
176
|
-
# alike, since both carry their sub-fields under :fields.
|
|
177
|
-
|
|
178
|
-
|
|
246
|
+
# alike, since both carry their sub-fields under :fields. A :json
|
|
247
|
+
# field is opaque: it has no :fields, yet lets any nested key through,
|
|
248
|
+
# so a path running past one is recorded rather than called missing,
|
|
249
|
+
# along with how many container levels the rest of the path needs.
|
|
250
|
+
def resolve_field(fields, segments, depth = 0)
|
|
251
|
+
name = segments[depth][:name].to_sym
|
|
179
252
|
field = fields.find { |f| f[:name] == name }
|
|
180
253
|
if field.nil?
|
|
181
254
|
@missing_among = fields
|
|
182
255
|
return nil
|
|
183
256
|
end
|
|
184
|
-
return field if segments.length
|
|
257
|
+
return field if depth == segments.length - 1
|
|
258
|
+
|
|
259
|
+
if field[:kind] == :json
|
|
260
|
+
@opaque = { path: segments.take(depth + 1).map { |seg| seg[:name] }.join("."), field: field,
|
|
261
|
+
steps: steps_below(segments, depth) }
|
|
262
|
+
return nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
resolve_field(field[:fields] || [], segments, depth + 1)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Each key or [n] step past the :json field descends into one more
|
|
269
|
+
# container — the same count the runtime's max_depth: check makes,
|
|
270
|
+
# where the field's own Hash is the first level and arrays count too.
|
|
271
|
+
def steps_below(segments, depth)
|
|
272
|
+
(segments.length - depth - 1) + segments.drop(depth).sum { |seg| seg[:indexes] }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def opaque_within_depth?
|
|
276
|
+
limit = @opaque[:field][:max_depth]
|
|
277
|
+
limit.nil? || @opaque[:steps] <= limit
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Paths are relative to root:, so "user.email" under `root: :user`
|
|
281
|
+
# names params[:user][:user][:email]. When dropping the prefix would
|
|
282
|
+
# resolve (to a declared field, or into an opaque :json one within its
|
|
283
|
+
# max_depth:), that is almost certainly what was meant — and silently
|
|
284
|
+
# passing a negated expectation on it would be a false pass.
|
|
285
|
+
def root_prefixed?
|
|
286
|
+
root = @rule[:root]
|
|
287
|
+
return false unless root && @segments.length > 1 && @segments.first[:name] == root.to_s
|
|
185
288
|
|
|
186
|
-
|
|
289
|
+
missing_among = @missing_among
|
|
290
|
+
found = resolve_field(@rule[:fields], @segments.drop(1)) || (@opaque && opaque_within_depth?)
|
|
291
|
+
@missing_among = missing_among
|
|
292
|
+
@opaque = nil
|
|
293
|
+
found ? true : false
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def root_prefix_hint
|
|
297
|
+
relative = @segments.drop(1).map { |seg| seg[:raw] }.join(".")
|
|
298
|
+
"paths are relative to root: :#{@rule[:root]}, so write permit_param(#{label_for(relative)})"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def opaque_qualifier_mismatch
|
|
302
|
+
"it is inside the opaque :json field #{label_for(@opaque[:path])}, which declares nothing about its keys — " \
|
|
303
|
+
"assert qualifiers on #{label_for(@opaque[:path])} itself"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Accepts the runtime's own path form too — violation details say
|
|
307
|
+
# "line_items[0].sku" — so a path copied from one resolves: the [n]
|
|
308
|
+
# indexes are dropped for the walk and kept only as depth.
|
|
309
|
+
def path_segments!(path)
|
|
310
|
+
raws = path.split(".", -1)
|
|
311
|
+
matches = raws.map { |raw| SEGMENT.match(raw) }
|
|
312
|
+
if raws.empty? || matches.any?(&:nil?)
|
|
313
|
+
raise ArgumentError, "#{LABEL}: permit_param needs a param name or a dotted path like " \
|
|
314
|
+
"\"address.zip\" or \"line_items[0].sku\" (got #{path.inspect})"
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
raws.zip(matches).map { |raw, m| { name: m[1], indexes: m[2].count("["), raw: raw } }
|
|
187
318
|
end
|
|
188
319
|
|
|
189
320
|
def collect_mismatches(field)
|
|
@@ -194,15 +325,31 @@ module Permittable
|
|
|
194
325
|
case key
|
|
195
326
|
when :type then type_mismatch(field, value)
|
|
196
327
|
when :array then "expected an array field, but it is declared with `#{field[:kind]}`" unless field[:kind] == :array
|
|
197
|
-
when :of then
|
|
328
|
+
when :of then of_mismatch(field, value)
|
|
198
329
|
when :required then required_mismatch(field, value)
|
|
330
|
+
when :format then format_mismatch(field, value)
|
|
331
|
+
# Compared cast, but reported as written.
|
|
332
|
+
when :default then default_mismatch(field, value)
|
|
333
|
+
when :in then option_mismatch(field, :in, value) unless field.key?(:in) && same_in?(field[:in], cast_in(field, value))
|
|
199
334
|
when :virtual, :sensitive, :nullable then "expected the field to be #{key}, but it is not" unless field[key]
|
|
200
335
|
else option_mismatch(field, key, value)
|
|
201
336
|
end
|
|
202
337
|
end
|
|
203
338
|
|
|
339
|
+
# A non-array field is already reported by the :array check that
|
|
340
|
+
# as_array always chains alongside :of, so this stays silent for it;
|
|
341
|
+
# an array of hashes has sub-fields rather than an element type.
|
|
342
|
+
def of_mismatch(field, type)
|
|
343
|
+
return if field[:kind] != :array || field[:of] == type
|
|
344
|
+
return "expected an array of :#{type}, but :#{field[:name]} is an array of hashes" if field[:fields]
|
|
345
|
+
|
|
346
|
+
"expected an array of :#{type}, but it is of: :#{field[:of]}"
|
|
347
|
+
end
|
|
348
|
+
|
|
204
349
|
def type_mismatch(field, type)
|
|
205
|
-
if field[:kind] == :array
|
|
350
|
+
if field[:kind] == :array && field[:fields]
|
|
351
|
+
"expected type :#{type}, but :#{field[:name]} is an array of hashes — assert it with as_array"
|
|
352
|
+
elsif field[:kind] == :array
|
|
206
353
|
"expected type :#{type}, but :#{field[:name]} is an array — assert it with as_array(of: ...)"
|
|
207
354
|
elsif field[:kind] == :nested
|
|
208
355
|
"expected type :#{type}, but :#{field[:name]} is a nested hash"
|
|
@@ -217,6 +364,79 @@ module Permittable
|
|
|
217
364
|
"expected the field to be #{expected}, but it is #{actual}" unless actual == expected
|
|
218
365
|
end
|
|
219
366
|
|
|
367
|
+
# `matching(:email)` asserts the preset by name, `matching(/re/)` the
|
|
368
|
+
# Regexp itself.
|
|
369
|
+
def format_mismatch(field, expected)
|
|
370
|
+
return option_mismatch(field, :format, expected) unless expected.is_a?(Symbol)
|
|
371
|
+
return if field[:format_name] == expected
|
|
372
|
+
|
|
373
|
+
"expected format: :#{expected}, but the contract #{declared_format(field)}"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def default_mismatch(field, expected)
|
|
377
|
+
option_mismatch(field, :default, expected) unless field.key?(:default) && field[:default] == cast_default(field, expected)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# A contract stores its `default:` as the field reads it — cast,
|
|
381
|
+
# normalized, and for an array read by the request walker — so
|
|
382
|
+
# `with_default("18")` on an :integer, the declaration repeated as
|
|
383
|
+
# written, is read the same way before comparing, by the same code. A
|
|
384
|
+
# value that does not read cleanly is compared as given, and the
|
|
385
|
+
# failure shows both sides.
|
|
386
|
+
#
|
|
387
|
+
# A field declaring `transform:` stores its default AS AUTHORED instead
|
|
388
|
+
# (see validate_authored_value!/validate_array_authored_value!), so
|
|
389
|
+
# `expected` is compared bare, not cast — the matcher would otherwise
|
|
390
|
+
# compare a cast value against an uncast stored one and never match.
|
|
391
|
+
def cast_default(field, expected)
|
|
392
|
+
return expected if field[:transform]
|
|
393
|
+
|
|
394
|
+
case field[:kind]
|
|
395
|
+
when :scalar
|
|
396
|
+
# Copied first, like a request's String, so a mutating `normalize:`
|
|
397
|
+
# proc cannot rewrite the spec's own literal (the same reason
|
|
398
|
+
# validate_authored_value! copies before normalizing).
|
|
399
|
+
own = expected.is_a?(String) ? expected.dup : expected
|
|
400
|
+
status, value = Coercion.cast(field[:type], Coercion.apply_normalize(field[:normalize], own))
|
|
401
|
+
status == :ok ? value : expected
|
|
402
|
+
when :array
|
|
403
|
+
return expected unless expected.is_a?(Array)
|
|
404
|
+
|
|
405
|
+
value, violations = AuthoredValues.read_array(field, expected)
|
|
406
|
+
violations.empty? ? value : expected
|
|
407
|
+
else expected
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# A contract stores an `in:` list cast by the field's type, so
|
|
412
|
+
# `within(%i[draft published])` — the declaration repeated as written —
|
|
413
|
+
# is read the same way before comparing, by the same two functions the
|
|
414
|
+
# contract uses: what counts as a list (a Hash as its keys), then the
|
|
415
|
+
# cast. Anything that is not a list, or does not cast, is compared as
|
|
416
|
+
# given — a Range and a host's own allowlist are stored as given too.
|
|
417
|
+
def cast_in(field, expected)
|
|
418
|
+
members = field[:kind] == :scalar && Coercion.in_list(expected)
|
|
419
|
+
return expected unless members
|
|
420
|
+
|
|
421
|
+
status, cast = Coercion.cast_in_members(field[:type], members, nullable: field[:nullable])
|
|
422
|
+
status == :ok ? cast : expected
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# A list's order and container say nothing about what it allows:
|
|
426
|
+
# `in: Post.statuses` is stored as a Set, and `within(%w[draft
|
|
427
|
+
# published])` names exactly its values.
|
|
428
|
+
def same_in?(declared, expected)
|
|
429
|
+
lists = [declared, expected].all? { |list| list.is_a?(Array) || list.is_a?(Set) }
|
|
430
|
+
lists ? declared.to_set == expected.to_set : declared == expected
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def declared_format(field)
|
|
434
|
+
return "declares format: :#{field[:format_name]}" if field[:format_name]
|
|
435
|
+
return "declares format: #{field[:format].inspect}" if field[:format]
|
|
436
|
+
|
|
437
|
+
"does not declare format:"
|
|
438
|
+
end
|
|
439
|
+
|
|
220
440
|
def option_mismatch(field, key, value)
|
|
221
441
|
return if field.key?(key) && field[key] == value
|
|
222
442
|
|
|
@@ -225,6 +445,33 @@ module Permittable
|
|
|
225
445
|
"expected #{label} #{value.inspect}, but the contract #{declared}"
|
|
226
446
|
end
|
|
227
447
|
|
|
448
|
+
def negated_qualifier_message
|
|
449
|
+
"#{LABEL}: `not_to #{call_label}` cannot take qualifiers (here: #{descriptors.join(', ')}) — " \
|
|
450
|
+
"negating one is ambiguous, since it would pass both when #{path_label} is not declared and " \
|
|
451
|
+
"when it is declared differently. Assert what the contract does declare with " \
|
|
452
|
+
"the positive form, e.g. #{positive_example}, or drop the qualifiers to assert that " \
|
|
453
|
+
"#{path_label} is not declared at all."
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# required/optional is the one qualifier with an obvious opposite;
|
|
457
|
+
# for any other the declared value is not known until the rule is
|
|
458
|
+
# read, so the example stays generic rather than guessing it.
|
|
459
|
+
def positive_example
|
|
460
|
+
case @expected
|
|
461
|
+
when { required: true } then "`to #{call_label}.optional`"
|
|
462
|
+
when { required: false } then "`to #{call_label}.required`"
|
|
463
|
+
else "`to #{call_label}` chained with the qualifiers it should have"
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def call_label
|
|
468
|
+
"permit_param(#{path_label})#{".for_action(:#{@action})" if @action}"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def descriptors
|
|
472
|
+
@expected.filter_map { |key, value| describe_check(key, value) }
|
|
473
|
+
end
|
|
474
|
+
|
|
228
475
|
def describe_check(key, value)
|
|
229
476
|
case key
|
|
230
477
|
when :type then "as :#{value}"
|
|
@@ -241,7 +488,186 @@ module Permittable
|
|
|
241
488
|
end
|
|
242
489
|
|
|
243
490
|
def path_label
|
|
244
|
-
@path
|
|
491
|
+
label_for(@path)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def label_for(path)
|
|
495
|
+
path.include?(".") ? path.inspect : ":#{path}"
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def action_label
|
|
499
|
+
@action ? " for ##{@action}" : ""
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Runs a declared contract against a payload and asserts on the outcome —
|
|
504
|
+
# the behavioural counterpart to PermitParamMatcher, which asserts on the
|
|
505
|
+
# declaration. Shares its subject and rule resolution, so `for_action` picks
|
|
506
|
+
# the rule exactly as a request would and ambiguity fails loudly.
|
|
507
|
+
class ParamsBehaviourMatcher
|
|
508
|
+
def initialize(params, expect_accepted:)
|
|
509
|
+
@params = params
|
|
510
|
+
@expect_accepted = expect_accepted
|
|
511
|
+
@expected_violations = []
|
|
512
|
+
@action = nil
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# -- chains -------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
def for_action(action)
|
|
518
|
+
@action = action.to_s
|
|
519
|
+
self
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# accept_params only: assert the cast, defaulted, transformed output.
|
|
523
|
+
def returning(hash)
|
|
524
|
+
@returning = hash
|
|
525
|
+
self
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# reject_params only: assert a particular violation is among those
|
|
529
|
+
# recorded. Repeatable; the code is optional.
|
|
530
|
+
def with_violation(param, code = nil)
|
|
531
|
+
@expected_violations << { param: param.to_s, code: code&.to_s }
|
|
532
|
+
self
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# -- RSpec protocol -----------------------------------------------------
|
|
536
|
+
|
|
537
|
+
def matches?(subject)
|
|
538
|
+
@subject = resolve_subject(subject)
|
|
539
|
+
rule = resolve_rule
|
|
540
|
+
return false unless rule
|
|
541
|
+
|
|
542
|
+
@violations, @result = run(rule)
|
|
543
|
+
@expect_accepted ? accepted_ok? : rejected_ok?
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def failure_message
|
|
547
|
+
return "expected #{subject_name} to #{description}, but it #{@problem}" if @problem
|
|
548
|
+
|
|
549
|
+
if @expect_accepted
|
|
550
|
+
return "expected #{subject_name} to #{description}, but it rejected them: #{summary(@violations)}" unless @violations.empty?
|
|
551
|
+
|
|
552
|
+
"expected #{subject_name} to #{description}, but it accepted them but returned #{@result.to_h.inspect}"
|
|
553
|
+
else
|
|
554
|
+
return "expected #{subject_name} to #{description}, but it accepted them, returning #{@result.to_h.inspect}" if @violations.empty?
|
|
555
|
+
|
|
556
|
+
"expected #{subject_name} to #{description}, but the violations were: #{summary(@violations)}"
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def failure_message_when_negated
|
|
561
|
+
verb = @expect_accepted ? "accept" : "reject"
|
|
562
|
+
"expected #{subject_name} not to #{verb} those params#{action_label}, but it did"
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def description
|
|
566
|
+
label = @expect_accepted ? "accept those params" : "reject those params"
|
|
567
|
+
label += action_label
|
|
568
|
+
label += " with #{summary(@expected_violations)}" unless @expected_violations.empty?
|
|
569
|
+
label += " returning #{@returning.inspect}" if @returning
|
|
570
|
+
label
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def supports_block_expectations?
|
|
574
|
+
false
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
private
|
|
578
|
+
|
|
579
|
+
def accepted_ok?
|
|
580
|
+
return false unless @violations.empty?
|
|
581
|
+
|
|
582
|
+
@returning.nil? || @result.to_h == ActiveSupport::HashWithIndifferentAccess.new(@returning).to_h
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def rejected_ok?
|
|
586
|
+
return false if @violations.empty?
|
|
587
|
+
|
|
588
|
+
@expected_violations.all? do |expected|
|
|
589
|
+
@violations.any? do |actual|
|
|
590
|
+
actual[:param] == expected[:param] && (expected[:code].nil? || actual[:code] == expected[:code])
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# A throwaway host carrying just this rule, forced to :enforce. The
|
|
596
|
+
# question these matchers answer is what the CONTRACT says, not what the
|
|
597
|
+
# current rollout mode does with it — so a monitor-mode rule still reports
|
|
598
|
+
# its violations here.
|
|
599
|
+
#
|
|
600
|
+
# The host is a bare `include Permittable` class rather than a subclass
|
|
601
|
+
# of @subject's own class: @subject may be a real controller, and
|
|
602
|
+
# instantiating one outside the framework's own dispatch (no request,
|
|
603
|
+
# no response, whatever else its own before_actions assume) is not
|
|
604
|
+
# safe in general. That means it does not automatically inherit any
|
|
605
|
+
# override @subject's class makes to permittable_check_unknown — which
|
|
606
|
+
# is exactly what a standalone Permittable::Contract relies on
|
|
607
|
+
# (Contract#initialize forces top_level: false, since standalone input
|
|
608
|
+
# has no router to exempt routing keys for). So that one override is
|
|
609
|
+
# replicated here for a Contract subject specifically, keeping the
|
|
610
|
+
# controller path (which is presumed to use the plain module default)
|
|
611
|
+
# byte-for-byte unchanged.
|
|
612
|
+
def run(rule)
|
|
613
|
+
host = Class.new do
|
|
614
|
+
include Permittable
|
|
615
|
+
|
|
616
|
+
attr_accessor :params
|
|
617
|
+
end
|
|
618
|
+
mirror_contract_unknown_check(host) if @subject.is_a?(Permittable::Contract)
|
|
619
|
+
host.permittable_contracts = [rule.merge(mode: :enforce).freeze]
|
|
620
|
+
instance = host.new
|
|
621
|
+
instance.params = @params
|
|
622
|
+
action = @action || rule[:actions].first || "call"
|
|
623
|
+
violations = instance.permittable_violations(action)
|
|
624
|
+
result = violations.empty? ? instance.permitted_params(action) : nil
|
|
625
|
+
[violations, result]
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# Mirrors Permittable::Contract's own host override verbatim (see
|
|
629
|
+
# lib/permittable/contract.rb): standalone input has no router and no
|
|
630
|
+
# request, so nothing is exempt from unknown: checking, top level or
|
|
631
|
+
# not.
|
|
632
|
+
def mirror_contract_unknown_check(host)
|
|
633
|
+
host.define_method(:permittable_check_unknown) do |fields, hash, path:, unknown:, top_level:, violations:| # rubocop:disable Lint/UnusedBlockArgument
|
|
634
|
+
super(fields, hash, path: path, unknown: unknown, top_level: false, violations: violations)
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def resolve_rule
|
|
639
|
+
if @action
|
|
640
|
+
@subject.permit_rule_for(@action) || record_problem("has no contract covering ##{@action}")
|
|
641
|
+
else
|
|
642
|
+
contracts = @subject.permittable_contracts
|
|
643
|
+
case contracts.length
|
|
644
|
+
when 0 then record_problem("declares no contracts")
|
|
645
|
+
when 1 then contracts.first
|
|
646
|
+
else
|
|
647
|
+
raise ArgumentError, "#{LABEL}: #{subject_name} declares #{contracts.length} contracts — " \
|
|
648
|
+
"disambiguate with accept_params(...).for_action(:action)"
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def record_problem(problem)
|
|
654
|
+
@problem = problem
|
|
655
|
+
nil
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def resolve_subject(subject)
|
|
659
|
+
return subject if subject.respond_to?(:permit_rule_for)
|
|
660
|
+
return subject.class if subject.class.respond_to?(:permit_rule_for)
|
|
661
|
+
|
|
662
|
+
raise ArgumentError, "#{LABEL}: the subject of accept_params/reject_params must include Permittable (got #{subject.inspect})"
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def summary(violations)
|
|
666
|
+
violations.map { |v| v[:code] ? "#{v[:param]} (#{v[:code]})" : v[:param] }.join(", ")
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def subject_name
|
|
670
|
+
(@subject.respond_to?(:name) && @subject.name) || "the contract"
|
|
245
671
|
end
|
|
246
672
|
|
|
247
673
|
def action_label
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Reports contract coverage across the whole route set: which routed actions
|
|
2
|
+
# validate their input, in which mode, guarded by which model — and which do
|
|
3
|
+
# not. The gap that matters is a POST/PUT/PATCH action with no contract, where
|
|
4
|
+
# untrusted input reaches the action unchecked.
|
|
5
|
+
#
|
|
6
|
+
# bin/rails permittable:audit # the table plus a summary
|
|
7
|
+
# bin/rails "permittable:audit[strict]" # ...and exit 1 on any unguarded
|
|
8
|
+
# # write action, as a CI gate
|
|
9
|
+
namespace :permittable do
|
|
10
|
+
desc "Report contract coverage across the route set (pass [strict] to fail on unguarded write actions)"
|
|
11
|
+
task :audit, [:strict] => :environment do |_t, task_args|
|
|
12
|
+
Rails.application.eager_load!
|
|
13
|
+
|
|
14
|
+
bases = []
|
|
15
|
+
bases << ActionController::Base if defined?(ActionController::Base)
|
|
16
|
+
bases << ActionController::API if defined?(ActionController::API)
|
|
17
|
+
# Every controller, not just the ones including Permittable — a controller
|
|
18
|
+
# that never included it is unguarded, which is exactly the finding.
|
|
19
|
+
controllers = bases.flat_map(&:descendants).uniq.select(&:name)
|
|
20
|
+
routes = Permittable::OpenAPI.rails_routes(Rails.application)
|
|
21
|
+
|
|
22
|
+
entries = Permittable::Audit.entries(controllers: controllers, routes: routes)
|
|
23
|
+
stale = Permittable::Audit.stale(controllers: controllers, routes: routes)
|
|
24
|
+
print Permittable::Audit.format(entries, stale: stale)
|
|
25
|
+
|
|
26
|
+
next unless task_args[:strict]
|
|
27
|
+
|
|
28
|
+
gap = Permittable::Audit.summary(entries)[:uncovered_with_body]
|
|
29
|
+
next if gap.zero?
|
|
30
|
+
|
|
31
|
+
abort "\nPermittable: #{gap} routed action#{'s' unless gap == 1} " \
|
|
32
|
+
"accept#{'s' if gap == 1} a request body with no contract covering it."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Drafts Permittable contracts for controllers that don't declare one yet,
|
|
2
|
-
# from each controller's model columns plus any params.permit
|
|
3
|
-
# source. Drafts go to stdout (paste-ready); the
|
|
2
|
+
# from each controller's model columns plus any params.permit or Rails 8
|
|
3
|
+
# params.expect calls in its source. Drafts go to stdout (paste-ready); the
|
|
4
|
+
# summary goes to stderr.
|
|
4
5
|
#
|
|
5
6
|
# bin/rails permittable:generate # every uncovered controller
|
|
6
7
|
# bin/rails "permittable:generate[UsersController]" # one controller, even if covered
|
data/lib/permittable/version.rb
CHANGED