permittable 0.10.0 → 0.11.1
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 +72 -0
- data/README.md +435 -32
- data/lib/permittable/audit.rb +241 -24
- data/lib/permittable/authored_values.rb +70 -10
- data/lib/permittable/column_guard.rb +31 -11
- data/lib/permittable/contract.rb +50 -1
- data/lib/permittable/error_envelope.rb +60 -6
- data/lib/permittable/generator.rb +335 -66
- data/lib/permittable/json_schema.rb +102 -15
- data/lib/permittable/open_api.rb +470 -81
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +49 -7
- data/lib/permittable/tasks/generate.rake +11 -6
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +367 -69
- metadata +2 -2
data/lib/permittable/audit.rb
CHANGED
|
@@ -15,6 +15,11 @@ module Permittable
|
|
|
15
15
|
# bin/rails permittable:audit[strict] # ...and exit 1 if a write action
|
|
16
16
|
# # is unguarded (a CI gate)
|
|
17
17
|
#
|
|
18
|
+
# What is not the app's to guard — ActiveStorage's direct uploads, a
|
|
19
|
+
# catch-all 404 — is left out with Permittable.audit_ignore, and every
|
|
20
|
+
# controller whose source is outside Rails.root is left out by default.
|
|
21
|
+
# Either way it leaves the counts and [strict], never the report.
|
|
22
|
+
#
|
|
18
23
|
# Plain Ruby over the frozen registry and a list of route descriptors (the
|
|
19
24
|
# same `{ controller:, action:, verb:, path: }` shape OpenAPI.rails_routes
|
|
20
25
|
# produces, plus the optional `route:` it tags each one with), so it is
|
|
@@ -30,6 +35,14 @@ module Permittable
|
|
|
30
35
|
# is usually fine; a POST without one is the finding.
|
|
31
36
|
BODY_VERBS = %w[post put patch].freeze
|
|
32
37
|
|
|
38
|
+
# Why an entry is left out of the counts, keyed as Entry#ignored holds it,
|
|
39
|
+
# with the heading its section of the report carries. Listed in this
|
|
40
|
+
# order: what the app chose to ignore first, then what it got by default.
|
|
41
|
+
IGNORE_REASONS = {
|
|
42
|
+
configured: "Ignored by Permittable.audit_ignore (left out of the counts above):",
|
|
43
|
+
outside_root: "Ignored as outside the app root (set Permittable.audit_ignore_outside_root = false to audit them):"
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
33
46
|
# One routed action, paired with the rule a request for it would resolve
|
|
34
47
|
# through (nil when nothing covers it — including when the controller
|
|
35
48
|
# never included the concern, which is exactly the case worth finding).
|
|
@@ -37,8 +50,10 @@ module Permittable
|
|
|
37
50
|
# `missing_action` marks a route to an action the controller does not
|
|
38
51
|
# define — see Audit.missing_action?. `route` identifies the route the
|
|
39
52
|
# path came from (nil for a hand-built descriptor, which is then its own
|
|
40
|
-
# route); see `summary`.
|
|
41
|
-
|
|
53
|
+
# route); see `summary`. `ignored` is why the entry is left out of the
|
|
54
|
+
# counts — a key of IGNORE_REASONS — or nil when it is audited.
|
|
55
|
+
Entry = Struct.new(:controller, :action, :verb, :path, :rule, :missing_action, :route, :ignored,
|
|
56
|
+
keyword_init: true) do
|
|
42
57
|
def covered?
|
|
43
58
|
!rule.nil?
|
|
44
59
|
end
|
|
@@ -47,6 +62,10 @@ module Permittable
|
|
|
47
62
|
missing_action == true
|
|
48
63
|
end
|
|
49
64
|
|
|
65
|
+
def ignored?
|
|
66
|
+
!ignored.nil?
|
|
67
|
+
end
|
|
68
|
+
|
|
50
69
|
# The mode this action would actually run in, rule-level declaration
|
|
51
70
|
# first and the app-wide default behind it.
|
|
52
71
|
def mode
|
|
@@ -69,25 +88,137 @@ module Permittable
|
|
|
69
88
|
# Every routed action of every given controller, sorted for stable output.
|
|
70
89
|
# Pass ALL controllers, not just the ones including Permittable — a
|
|
71
90
|
# controller that never included it is unguarded, which is the point.
|
|
72
|
-
|
|
91
|
+
#
|
|
92
|
+
# Ignored actions are returned too, marked with why (Entry#ignored):
|
|
93
|
+
# `ignore:` is the Permittable.audit_ignore list, and `root:` the app
|
|
94
|
+
# root a controller's source must be inside (see app_root; nil ignores
|
|
95
|
+
# nothing on that ground). Both default to the app's own configuration,
|
|
96
|
+
# as `mode` does.
|
|
97
|
+
def entries(controllers:, routes:, ignore: Permittable.audit_ignore, root: app_root)
|
|
73
98
|
routes = routes.to_a
|
|
74
|
-
controllers.flat_map { |controller| controller_entries(controller, routes) }
|
|
99
|
+
controllers.flat_map { |controller| controller_entries(controller, routes, ignore: ignore, root: root) }
|
|
75
100
|
.sort_by { |entry| [entry.controller, entry.path, entry.verb.to_s] }
|
|
76
101
|
end
|
|
77
102
|
|
|
78
|
-
|
|
103
|
+
# The app's own choice outranks the default: a controller that is both
|
|
104
|
+
# configured and outside the root is reported as configured, so its entry
|
|
105
|
+
# counts as matched (see unmatched_ignores) — the README's own example
|
|
106
|
+
# names ActiveStorage, which the default already covers. The location is
|
|
107
|
+
# looked up once per controller, not once per route.
|
|
108
|
+
def controller_entries(controller, routes, ignore: Permittable.audit_ignore, root: app_root)
|
|
79
109
|
key = OpenAPI.controller_key(controller) || controller.inspect
|
|
80
110
|
missing = missing_lookup(controller)
|
|
111
|
+
outside = :outside_root if outside_root?(controller, root)
|
|
81
112
|
routes.select { |route| route[:controller].to_s == key }.map do |route|
|
|
82
113
|
action = route[:action].to_s
|
|
83
114
|
Entry.new(controller: key, action: action, verb: route[:verb], path: route[:path],
|
|
84
115
|
rule: rule_for(controller, action), missing_action: missing[action],
|
|
85
|
-
route: route[:route])
|
|
116
|
+
route: route[:route], ignored: configured_ignore(key, action, ignore) || outside)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Why `controller` — or one of its actions — is left out of the audit and
|
|
121
|
+
# of permittable:generate: :configured (Permittable.audit_ignore names the
|
|
122
|
+
# controller, or "controller#action"), :outside_root, or nil. The
|
|
123
|
+
# generator asks per action; see Generator.targets.
|
|
124
|
+
def ignore_reason(controller, action = nil, ignore: Permittable.audit_ignore, root: app_root)
|
|
125
|
+
key = OpenAPI.controller_key(controller) || controller.inspect
|
|
126
|
+
configured_ignore(key, action, ignore) || (:outside_root if outside_root?(controller, root))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def configured_ignore(key, action, ignore)
|
|
130
|
+
:configured if ignore.include?(key) || (action && ignore.include?("#{key}##{action}"))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The ignore entries no routed action answers to — a controller that is
|
|
134
|
+
# not routed, an action it does not route, or a typo of either. Ignored
|
|
135
|
+
# entries count as matches: the question is whether the entry names
|
|
136
|
+
# something, not whether it was the only reason that something was
|
|
137
|
+
# ignored.
|
|
138
|
+
def unmatched_ignores(entries, ignore: Permittable.audit_ignore)
|
|
139
|
+
ignore.reject do |item|
|
|
140
|
+
key, action = item.split("#", 2)
|
|
141
|
+
entries.any? { |entry| entry.controller == key && (action.nil? || entry.action == action) }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The root a controller's source must be inside to be audited:
|
|
146
|
+
# Rails.root, unless Permittable.audit_ignore_outside_root is off — or nil
|
|
147
|
+
# without Rails, where there is no app to be outside of.
|
|
148
|
+
def app_root
|
|
149
|
+
return nil unless Permittable.audit_ignore_outside_root
|
|
150
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:root)
|
|
151
|
+
|
|
152
|
+
::Rails.root&.to_s
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Whether `controller` is an engine's or a gem's rather than the app's:
|
|
156
|
+
# its source is outside `root`, or inside a directory gems are installed
|
|
157
|
+
# in — `bundle config path vendor/bundle`, which CI caches and Docker
|
|
158
|
+
# images commonly use, puts every gem inside the app's root, and
|
|
159
|
+
# ActiveStorage's controller is no more the app's for being there. A gem
|
|
160
|
+
# directory that holds the root itself (a GEM_HOME set to the app, or to
|
|
161
|
+
# a parent of it) says nothing about which side a file is on, so it is
|
|
162
|
+
# not consulted — otherwise every app controller would read as a gem's
|
|
163
|
+
# and [strict] would pass on nothing. A controller that cannot be located
|
|
164
|
+
# is audited: for a gate, a false alarm beats a missed endpoint.
|
|
165
|
+
def outside_root?(controller, root)
|
|
166
|
+
return false if root.nil?
|
|
167
|
+
|
|
168
|
+
path = source_path(controller)
|
|
169
|
+
return false if path.nil?
|
|
170
|
+
|
|
171
|
+
!within?(path, root) || Gem.path.any? { |dir| within?(path, dir) && !within?(root, dir) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Where `controller` is defined: its constant's own definition (the
|
|
175
|
+
# `class` line — reopening a gem's controller in app/ does not move it),
|
|
176
|
+
# or, for a class whose constant cannot be found, the first of its own
|
|
177
|
+
# methods that has a location. nil when neither says.
|
|
178
|
+
#
|
|
179
|
+
# A constant that is registered for autoload but not loaded reports the
|
|
180
|
+
# `autoload` call instead — never the case here, since only loaded
|
|
181
|
+
# classes have descendants to audit.
|
|
182
|
+
def source_path(controller)
|
|
183
|
+
name = controller.name if controller.respond_to?(:name)
|
|
184
|
+
located = begin
|
|
185
|
+
# [] for a constant defined in C, so `first` is nil there too.
|
|
186
|
+
Object.const_source_location(name)&.first if name.is_a?(String) && !name.empty?
|
|
187
|
+
rescue NameError
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
located || method_source_path(controller)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def method_source_path(controller)
|
|
194
|
+
return nil unless controller.is_a?(Module)
|
|
195
|
+
|
|
196
|
+
methods = controller.instance_methods(false) + controller.private_instance_methods(false)
|
|
197
|
+
methods.lazy.filter_map { |name| controller.instance_method(name).source_location&.first }.first
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Whether `path` is `dir` or inside it, compared both as written and with
|
|
201
|
+
# symlinks resolved: Rails.root is a realpath, and on macOS a tmpdir under
|
|
202
|
+
# /var is really under /private/var — either side alone can disagree with
|
|
203
|
+
# the other, and an app controller misread as outside the root would drop
|
|
204
|
+
# out of [strict]. A form that cannot be resolved (a path that does not
|
|
205
|
+
# exist) is compared as written only.
|
|
206
|
+
def within?(path, dir)
|
|
207
|
+
[[File.expand_path(path), File.expand_path(dir)], [realpath(path), realpath(dir)]].any? do |file, base|
|
|
208
|
+
file && base && (file == base || file.start_with?(File.join(base, "")))
|
|
86
209
|
end
|
|
87
210
|
end
|
|
88
211
|
|
|
212
|
+
def realpath(path)
|
|
213
|
+
File.realpath(path)
|
|
214
|
+
rescue SystemCallError
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
|
|
89
218
|
# action => missing?, answered once per action: a `via: :all` route is
|
|
90
|
-
# five entries for one action
|
|
219
|
+
# up to five entries for one action (fewer when an earlier, unconstrained
|
|
220
|
+
# route already answers some of its verbs on that path, see
|
|
221
|
+
# OpenAPI.drop_shadowed), and each resolver call builds a controller.
|
|
91
222
|
# The action list is read once per controller, not once per route.
|
|
92
223
|
def missing_lookup(controller)
|
|
93
224
|
listed = listed_actions(controller)
|
|
@@ -144,20 +275,64 @@ module Permittable
|
|
|
144
275
|
# kind is in no summary bucket, so without this it would vanish from the
|
|
145
276
|
# report's conclusions. Catch-all rules declare no actions, so they never
|
|
146
277
|
# appear here.
|
|
278
|
+
#
|
|
279
|
+
# Judged per RULE, not per class: permittable_contracts is a
|
|
280
|
+
# class_attribute, so a subclass carries every rule its parent declared,
|
|
281
|
+
# and judging each class alone listed a shared base (routed to nothing)
|
|
282
|
+
# and every subclass routed to a different slice of its actions. A rule's
|
|
283
|
+
# action is stale only when NO controller carrying that rule reaches it —
|
|
284
|
+
# the class that declared it, or any descendant among `controllers:` — and
|
|
285
|
+
# it is listed once, under the declaring class, where the fix is made.
|
|
286
|
+
# Counting only the rules a class added itself would not do: the shared
|
|
287
|
+
# base still has no routes. Shadowing is not judged: an action some
|
|
288
|
+
# carrier reaches is not stale, even if that carrier declares its own rule
|
|
289
|
+
# for it, just as a rule redeclared within one class never was.
|
|
147
290
|
def stale(controllers:, routes:)
|
|
148
291
|
routes = routes.to_a
|
|
149
|
-
|
|
292
|
+
reaches = Hash.new { |memo, controller| memo[controller] = reach_lookup(controller, routes) }
|
|
293
|
+
carriers(controllers).each_with_object({}) do |(rule, held_by), found|
|
|
294
|
+
declarer = declaring_class(held_by.first, rule)
|
|
295
|
+
holders = held_by | [declarer]
|
|
296
|
+
left = rule[:actions].reject { |action| holders.any? { |controller| reaches[controller][action] } }
|
|
297
|
+
next if left.empty?
|
|
298
|
+
|
|
299
|
+
key = OpenAPI.controller_key(declarer) || declarer.inspect
|
|
300
|
+
found[key] = (found.fetch(key, []) + left).uniq
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# rule => the given controllers carrying it, in first-seen order. Keyed by
|
|
305
|
+
# IDENTITY: permit_params hands a subclass its parent's rule objects, while
|
|
306
|
+
# two classes declaring the same fields build equal but distinct rules.
|
|
307
|
+
def carriers(controllers)
|
|
308
|
+
controllers.each_with_object({}.compare_by_identity) do |controller, held|
|
|
150
309
|
next unless controller.respond_to?(:permittable_contracts)
|
|
151
310
|
|
|
152
|
-
|
|
153
|
-
routed = routes.select { |route| route[:controller].to_s == key }.map { |route| route[:action].to_s }
|
|
154
|
-
declared = controller.permittable_contracts.flat_map { |rule| rule[:actions] }.uniq
|
|
155
|
-
missing = missing_lookup(controller)
|
|
156
|
-
left = declared.select { |action| !routed.include?(action) || missing[action] }
|
|
157
|
-
found[key] = left unless left.empty?
|
|
311
|
+
controller.permittable_contracts.each { |rule| (held[rule] ||= []) << controller }
|
|
158
312
|
end
|
|
159
313
|
end
|
|
160
314
|
|
|
315
|
+
# action => whether a request for it reaches `controller`: routed there,
|
|
316
|
+
# and not to an action Rails would 404. Lazy, so the resolver is asked
|
|
317
|
+
# only about actions some contract declares.
|
|
318
|
+
def reach_lookup(controller, routes)
|
|
319
|
+
key = OpenAPI.controller_key(controller) || controller.inspect
|
|
320
|
+
routed = routes.select { |route| route[:controller].to_s == key }.to_set { |route| route[:action].to_s }
|
|
321
|
+
missing = missing_lookup(controller)
|
|
322
|
+
Hash.new { |memo, action| memo[action] = routed.include?(action) && !missing[action] }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# The highest ancestor still carrying `rule` — the class whose
|
|
326
|
+
# permit_params declared it, whether or not it was among `controllers:`.
|
|
327
|
+
def declaring_class(controller, rule)
|
|
328
|
+
controller = controller.superclass while controller.respond_to?(:superclass) && carries?(controller.superclass, rule)
|
|
329
|
+
controller
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def carries?(controller, rule)
|
|
333
|
+
controller.respond_to?(:permittable_contracts) && controller.permittable_contracts.any? { |held| held.equal?(rule) }
|
|
334
|
+
end
|
|
335
|
+
|
|
161
336
|
# The numbers worth putting in a CI log. `uncovered_with_body` is the one
|
|
162
337
|
# that should be zero; `unguarded_models` counts covered actions whose
|
|
163
338
|
# rule declares no `model:`, so no schema-drift guard runs for them.
|
|
@@ -172,8 +347,11 @@ module Permittable
|
|
|
172
347
|
# An action the controller does not define takes no body, and a contract
|
|
173
348
|
# left on one guards nothing, so it is counted as `missing_actions` and in
|
|
174
349
|
# no other bucket — the buckets stay disjoint and still add up to `actions`.
|
|
350
|
+
#
|
|
351
|
+
# An ignored entry (Entry#ignored) is in none of the counts, so it never
|
|
352
|
+
# fails [strict]; `format` lists it in a section of its own instead.
|
|
175
353
|
def summary(entries)
|
|
176
|
-
entries = routed(entries)
|
|
354
|
+
entries = routed(entries.reject(&:ignored?))
|
|
177
355
|
found, missing = entries.partition { |e| !e.missing_action? }
|
|
178
356
|
{
|
|
179
357
|
actions: entries.length,
|
|
@@ -196,16 +374,28 @@ module Permittable
|
|
|
196
374
|
end
|
|
197
375
|
|
|
198
376
|
# The human-readable report: one block per controller, then the summary,
|
|
199
|
-
# then anything stale.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
377
|
+
# then anything stale. The table and the summary cover the audited
|
|
378
|
+
# entries; what was ignored follows in sections of its own, then any
|
|
379
|
+
# ignore entry that matched nothing — `unmatched` defaults to checking
|
|
380
|
+
# Permittable.audit_ignore, the list `entries` defaults to.
|
|
381
|
+
#
|
|
382
|
+
# With no routed action at all there is no table or summary, but the
|
|
383
|
+
# stale and unmatched sections still follow: that is when a contract no
|
|
384
|
+
# route reaches, or an ignore entry naming nothing, most needs saying.
|
|
385
|
+
def format(entries, stale: {}, unmatched: unmatched_ignores(entries))
|
|
386
|
+
ignored, audited = entries.partition(&:ignored?)
|
|
387
|
+
out = entries.empty? ? ["Permittable audit: no routed actions to report."] : table_lines(audited)
|
|
205
388
|
out << stale_lines(stale) unless stale.empty?
|
|
389
|
+
out << ignored_lines(ignored) unless ignored.empty?
|
|
390
|
+
out << unmatched_lines(unmatched) unless unmatched.empty?
|
|
206
391
|
"#{out.join("\n")}\n"
|
|
207
392
|
end
|
|
208
393
|
|
|
394
|
+
def table_lines(entries)
|
|
395
|
+
entries.group_by(&:controller).map { |key, group| controller_block(key, group) } <<
|
|
396
|
+
summary_lines(summary(entries), rows: entries.length)
|
|
397
|
+
end
|
|
398
|
+
|
|
209
399
|
def controller_block(key, group)
|
|
210
400
|
rows = group.map do |entry|
|
|
211
401
|
" #{entry.verb.to_s.upcase.ljust(6)} #{entry.path.ljust(34)} #{entry.action.ljust(12)} #{status(entry)}"
|
|
@@ -242,9 +432,14 @@ module Permittable
|
|
|
242
432
|
# say WHY they differ: an optional segment's variants are the usual
|
|
243
433
|
# reason, but two concatenated route lists sharing a controller, action
|
|
244
434
|
# and index collapse the same way.
|
|
435
|
+
#
|
|
436
|
+
# Each line agrees with its own count: "1 of those accepts", "1 covered
|
|
437
|
+
# action declares ... for it". `rows` needs no singular — it only prints
|
|
438
|
+
# when it exceeds the route count, so it is at least 2.
|
|
245
439
|
def summary_lines(counts, rows: counts[:actions])
|
|
246
440
|
body = counts[:uncovered_with_body]
|
|
247
441
|
missing = counts[:missing_actions]
|
|
442
|
+
models = counts[:unguarded_models]
|
|
248
443
|
# Its own bucket, so the head line still adds up to the route count.
|
|
249
444
|
not_found = ", #{missing} not found (Rails 404s #{missing == 1 ? 'it' : 'them'})" unless missing.zero?
|
|
250
445
|
listed = " in #{rows} rows" unless rows == counts[:actions]
|
|
@@ -253,9 +448,10 @@ module Permittable
|
|
|
253
448
|
"#{counts[:actions]} routed action#{'s' unless counts[:actions] == 1}#{listed}: " \
|
|
254
449
|
"#{counts[:enforced]} enforced, #{counts[:monitored]} in monitor mode, " \
|
|
255
450
|
"#{counts[:uncovered]} without a contract#{not_found}",
|
|
256
|
-
" #{body} of those accept
|
|
257
|
-
"
|
|
258
|
-
"
|
|
451
|
+
" #{body} of those accept#{'s' if body == 1} a request body" \
|
|
452
|
+
"#{' — untrusted input reaches the action unchecked' unless body.zero?}",
|
|
453
|
+
" #{models} covered action#{models == 1 ? ' declares' : 's declare'} no model:, " \
|
|
454
|
+
"so no schema-drift guard runs for #{models == 1 ? 'it' : 'them'}"
|
|
259
455
|
].join("\n")
|
|
260
456
|
end
|
|
261
457
|
|
|
@@ -264,5 +460,26 @@ module Permittable
|
|
|
264
460
|
"\nContracts declared for actions no route reaches or Rails would 404 (renamed or deleted?):\n" \
|
|
265
461
|
"#{pairs.map { |pair| " #{pair}" }.join("\n")}"
|
|
266
462
|
end
|
|
463
|
+
|
|
464
|
+
# One section per reason, in IGNORE_REASONS order, and one line per
|
|
465
|
+
# controller#action with every verb it is routed on: a `via: :all`
|
|
466
|
+
# catch-all is one line rather than five rows, so what was left out stays
|
|
467
|
+
# visible without crowding the table it was left out of.
|
|
468
|
+
def ignored_lines(ignored)
|
|
469
|
+
IGNORE_REASONS.filter_map do |reason, heading|
|
|
470
|
+
group = ignored.select { |entry| entry.ignored == reason }
|
|
471
|
+
next if group.empty?
|
|
472
|
+
|
|
473
|
+
verbs = group.group_by { |entry| "#{entry.controller}##{entry.action}" }
|
|
474
|
+
.transform_values { |list| list.map { |entry| entry.verb.to_s.upcase }.uniq.join(", ") }
|
|
475
|
+
width = verbs.keys.map(&:length).max
|
|
476
|
+
"\n#{heading}\n#{verbs.map { |pair, list| " #{pair.ljust(width)} #{list}" }.join("\n")}"
|
|
477
|
+
end.join("\n")
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def unmatched_lines(unmatched)
|
|
481
|
+
"\nPermittable.audit_ignore entries that match no routed action (a typo, or a route since removed?):\n" \
|
|
482
|
+
"#{unmatched.map { |item| " #{item}" }.join("\n")}"
|
|
483
|
+
end
|
|
267
484
|
end
|
|
268
485
|
end
|
|
@@ -7,15 +7,23 @@ module Permittable
|
|
|
7
7
|
# its own `transform:` — see permittable_transform below for exactly
|
|
8
8
|
# which field that is and why. A SUB-FIELD's own transform: still
|
|
9
9
|
# runs, so what gets stored is what an equivalent request would
|
|
10
|
-
# produce
|
|
10
|
+
# produce — for a default:, and, under a transforming array, only
|
|
11
|
+
# where an array-level validate: reads its output. An example: is
|
|
12
|
+
# read `as_sent`, which is what gets stored: no transform at any
|
|
13
|
+
# depth, no sub-field default filled in and no array-level validate:.
|
|
14
|
+
# When an array-level validate: exists somewhere in it, it is read
|
|
15
|
+
# once more first, like a default, to VALIDATE it as a request would
|
|
16
|
+
# be (see ContractBuilder, which decides). An example documents what a
|
|
17
|
+
# client SENDS, so it must not be read through app code that reshapes
|
|
18
|
+
# the value into what the action receives.
|
|
11
19
|
# * violations carry no `message:` — they become an ArgumentError for
|
|
12
20
|
# the contract's author, not a response for a client, and I18n may not
|
|
13
21
|
# be loaded yet.
|
|
14
22
|
class AuthoredValues
|
|
15
23
|
include Permittable
|
|
16
24
|
|
|
17
|
-
def self.read_array(field, value)
|
|
18
|
-
new.read_array(field, value)
|
|
25
|
+
def self.read_array(field, value, as_sent: false)
|
|
26
|
+
new.read_array(field, value, as_sent: as_sent)
|
|
19
27
|
end
|
|
20
28
|
|
|
21
29
|
def self.summary(violations)
|
|
@@ -23,9 +31,11 @@ module Permittable
|
|
|
23
31
|
end
|
|
24
32
|
|
|
25
33
|
# [value as a request would get it, violations]
|
|
26
|
-
def read_array(field, value)
|
|
34
|
+
def read_array(field, value, as_sent: false)
|
|
27
35
|
violations = []
|
|
28
36
|
@field = field
|
|
37
|
+
@as_sent = as_sent
|
|
38
|
+
@read_by_validate = fields_under_validate(field) if field[:transform]
|
|
29
39
|
read = permittable_check_array(field, value, path: field[:name].to_s, unknown: :ignore, violations: violations)
|
|
30
40
|
[read, violations]
|
|
31
41
|
end
|
|
@@ -45,17 +55,67 @@ module Permittable
|
|
|
45
55
|
# identical value silently diverge (and the exported OpenAPI default,
|
|
46
56
|
# which reads this same value, documents one the server never produces).
|
|
47
57
|
#
|
|
48
|
-
# When @field itself has a transform:,
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
58
|
+
# When @field itself has a transform:, it never runs here either way and
|
|
59
|
+
# its result is discarded anyway (validate_array_authored_value! stores
|
|
60
|
+
# the value AS AUTHORED instead — see its comment). A sub-field's
|
|
61
|
+
# transform: output is another matter: a request runs it, then the
|
|
62
|
+
# array-level `validate:` on that result, and only then the array's own
|
|
63
|
+
# transform. So a `validate:` that reads `i[:tags].is_a?(Array)` must be
|
|
64
|
+
# handed the transformed `tags`, or a contract every request satisfies
|
|
65
|
+
# fails at class load. But a transform: is app code (`Account.find`), so
|
|
66
|
+
# under such an array it runs ONLY where its output is consumed: for a
|
|
67
|
+
# field below an array declaring `validate:` (the walked array counts),
|
|
68
|
+
# not merely somewhere in the same subtree, where a `validate:` on a
|
|
69
|
+
# sibling branch reads nothing of it. Without an own transform: of its
|
|
70
|
+
# own, @field's read is what gets stored, so every sub-field's runs.
|
|
71
|
+
#
|
|
72
|
+
# `as_sent` (the stored read of an example:) runs none, at any depth.
|
|
53
73
|
def permittable_transform(field, value)
|
|
54
|
-
return value if @
|
|
74
|
+
return value if @as_sent || field.equal?(@field)
|
|
75
|
+
return value if @field[:transform] && !@read_by_validate.key?(field)
|
|
55
76
|
|
|
56
77
|
super
|
|
57
78
|
end
|
|
58
79
|
|
|
80
|
+
# The fields below `field` whose output an array's `validate:` sees: all
|
|
81
|
+
# those under an array that declares one, counting `field` itself, by
|
|
82
|
+
# identity (the walker hands the very field hashes it was given).
|
|
83
|
+
def fields_under_validate(field, found = {}.compare_by_identity, under_validate: false)
|
|
84
|
+
under_validate ||= field[:kind] == :array && field[:validate]
|
|
85
|
+
Array(field[:fields]).each do |sub|
|
|
86
|
+
found[sub] = true if under_validate
|
|
87
|
+
fields_under_validate(sub, found, under_validate: under_validate)
|
|
88
|
+
end
|
|
89
|
+
found
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Nor does it run an array-level validate:, which expects the transformed
|
|
93
|
+
# sub-fields a request hands it. The validating read has already run it
|
|
94
|
+
# on exactly those, so the stored read, which would hand it the
|
|
95
|
+
# untransformed ones, must not (it could raise on a shape it never sees).
|
|
96
|
+
def permittable_array_validator(field)
|
|
97
|
+
super unless @as_sent
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# What a client sends has no defaults filled in: an omitted sub-field
|
|
101
|
+
# stays omitted, at any depth, so the published example does not show a
|
|
102
|
+
# value (`cents: 0`) that no client sends. The walker fills them in; drop
|
|
103
|
+
# exactly what it filled, by the walker's own absence rule.
|
|
104
|
+
def permittable_check_hash(fields, hash, **)
|
|
105
|
+
result = super
|
|
106
|
+
return result unless @as_sent
|
|
107
|
+
|
|
108
|
+
fields.each do |field|
|
|
109
|
+
key = field[:name].to_s
|
|
110
|
+
next unless field.key?(:default)
|
|
111
|
+
next unless permittable_absent?(permittable_normalized(field, hash[key]), hash, key)
|
|
112
|
+
next if permittable_explicit_null?(field, hash, key)
|
|
113
|
+
|
|
114
|
+
result.delete(key)
|
|
115
|
+
end
|
|
116
|
+
result
|
|
117
|
+
end
|
|
118
|
+
|
|
59
119
|
def permittable_violation(_field, param, code)
|
|
60
120
|
{ param: param, code: code.to_s }
|
|
61
121
|
end
|
|
@@ -33,8 +33,9 @@ module Permittable
|
|
|
33
33
|
# `types:` teaches the error message: a Symbol/String applies to every
|
|
34
34
|
# listed field, a Hash maps field => type. The raised ArgumentError then
|
|
35
35
|
# appends a ready-to-paste migration command. `allowed:` maps field =>
|
|
36
|
-
# its `in:`, for the fields that declare one
|
|
37
|
-
|
|
36
|
+
# its `in:`, for the fields that declare one, and `transformed:` lists
|
|
37
|
+
# the fields that declare a `transform:`; only the enum rule reads them.
|
|
38
|
+
def ensure_columns_on!(label, klass, *fields, types: nil, check_types: false, allowed: nil, transformed: nil)
|
|
38
39
|
return false unless schema_reachable?(klass)
|
|
39
40
|
|
|
40
41
|
fields.flatten.compact.each do |field|
|
|
@@ -44,7 +45,7 @@ module Permittable
|
|
|
44
45
|
"#{column_migration_hint(klass, field, types)}"
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
ensure_column_type!(label, klass, field, types, allowed) if check_types
|
|
48
|
+
ensure_column_type!(label, klass, field, types, allowed, transformed: transformed) if check_types
|
|
48
49
|
end
|
|
49
50
|
true
|
|
50
51
|
end
|
|
@@ -54,7 +55,7 @@ module Permittable
|
|
|
54
55
|
# TYPE_GROUPS) and stays silent unless both sides are known, so it can
|
|
55
56
|
# only fire on a genuine cross-family mismatch — a contract still saying
|
|
56
57
|
# :datetime after the column became a string, say.
|
|
57
|
-
def ensure_column_type!(label, klass, field, types, allowed = nil)
|
|
58
|
+
def ensure_column_type!(label, klass, field, types, allowed = nil, transformed: nil)
|
|
58
59
|
declared = types.is_a?(Hash) ? types[field.to_sym] : types
|
|
59
60
|
column = klass.columns_hash[field.to_s]
|
|
60
61
|
return unless declared && column
|
|
@@ -63,7 +64,10 @@ module Permittable
|
|
|
63
64
|
enum = enum_attribute?(klass, field)
|
|
64
65
|
# Checked before the column's type, which an enum's contract does not
|
|
65
66
|
# depend on: see ensure_enum_contract!.
|
|
66
|
-
|
|
67
|
+
if enum && wanted == :text
|
|
68
|
+
return ensure_enum_contract!(label, klass, field, declared, allowed && allowed[field.to_sym],
|
|
69
|
+
transformed: Array(transformed).include?(field.to_sym))
|
|
70
|
+
end
|
|
67
71
|
|
|
68
72
|
# `column.type` is nil for a SQL type the adapter does not recognise
|
|
69
73
|
# (a PostGIS geometry column without the extension loaded, a custom
|
|
@@ -107,25 +111,31 @@ module Permittable
|
|
|
107
111
|
# Accepted means what the enum's cast accepts: every name, plus every
|
|
108
112
|
# stored value that is a String (a string-backed enum takes `"p"` for
|
|
109
113
|
# `pro:` as readily as `"pro"`). An integer-backed enum's stored values
|
|
110
|
-
# are not accepted — a request carries `"0"`, which maps to nothing
|
|
111
|
-
#
|
|
112
|
-
#
|
|
114
|
+
# are not accepted — a request carries `"0"`, which maps to nothing —
|
|
115
|
+
# unless the field declares a `transform:`, which runs after `in:` and is
|
|
116
|
+
# how permittable:generate's TODO maps them back to names. Only the stored
|
|
117
|
+
# integers are admitted that way; the transform is app code, trusted to
|
|
118
|
+
# do what the TODO spells. A Range, or anything else without a finite
|
|
119
|
+
# list, cannot be checked, so it is refused rather than trusted.
|
|
113
120
|
#
|
|
114
121
|
# Only `enum` is recognised. The attribute API (`attribute :x, :datetime`
|
|
115
122
|
# over a string column) is left compared against the column by choice:
|
|
116
123
|
# an enum's mapping says exactly which strings are valid, an attribute
|
|
117
124
|
# override says nothing a contract could be checked against.
|
|
118
|
-
def ensure_enum_contract!(label, klass, field, declared, listed)
|
|
125
|
+
def ensure_enum_contract!(label, klass, field, declared, listed, transformed: false)
|
|
119
126
|
problem =
|
|
120
127
|
if listed.nil?
|
|
121
128
|
"declared :#{declared} without an in:"
|
|
122
129
|
elsif listed.is_a?(Range) || !listed.respond_to?(:to_a)
|
|
123
130
|
"declared :#{declared} with an in: that does not list its values"
|
|
124
131
|
else
|
|
125
|
-
|
|
132
|
+
accepted = enum_values(klass, field)
|
|
133
|
+
accepted += stored_integers(klass, field) if transformed
|
|
134
|
+
stray = listed.to_a - accepted
|
|
126
135
|
return if stray.empty?
|
|
127
136
|
|
|
128
|
-
"declared :#{declared} with an in: listing values it would refuse: #{stray.map(&:inspect).join(', ')}"
|
|
137
|
+
"declared :#{declared} with an in: listing values it would refuse: #{stray.map(&:inspect).join(', ')}" \
|
|
138
|
+
"#{stored_integer_hint(klass, field, stray, transformed)}"
|
|
129
139
|
end
|
|
130
140
|
|
|
131
141
|
raise ArgumentError,
|
|
@@ -139,6 +149,16 @@ module Permittable
|
|
|
139
149
|
mapping.keys.map(&:to_s) + mapping.values.grep(String)
|
|
140
150
|
end
|
|
141
151
|
|
|
152
|
+
def stored_integers(klass, field)
|
|
153
|
+
klass.defined_enums[field.to_s].values.grep(Integer).map(&:to_s)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def stored_integer_hint(klass, field, stray, transformed)
|
|
157
|
+
return "" if transformed || !stray.intersect?(stored_integers(klass, field))
|
|
158
|
+
|
|
159
|
+
" (a stored integer is accepted only beside a transform: mapping it back to its name)"
|
|
160
|
+
end
|
|
161
|
+
|
|
142
162
|
# `Order.statuses.keys` when the enum's plural reader exists, and the
|
|
143
163
|
# always-valid `defined_enums["..."]` spelling when it does not (a name
|
|
144
164
|
# that is not a Ruby identifier, a duck-typed model).
|
data/lib/permittable/contract.rb
CHANGED
|
@@ -62,10 +62,38 @@ module Permittable
|
|
|
62
62
|
super(fields, hash, path: path, unknown: unknown, top_level: false, violations: violations)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# unknown: :log warns through `logger`, and a controller gets one from
|
|
66
|
+
# ActionController. This host has none, so :log used to drop the keys
|
|
67
|
+
# without a word, exactly like :ignore. Rails.logger is read on every
|
|
68
|
+
# call, not at definition: contracts are usually constants, loaded
|
|
69
|
+
# before an app's initializers may have assigned it. With no Rails
|
|
70
|
+
# (a script, a job, a CSV import) the warning goes to $stderr rather
|
|
71
|
+
# than nowhere, since a :log that cannot log is :ignore by another
|
|
72
|
+
# name. Defining the contract does not raise for a missing logger:
|
|
73
|
+
# whether one exists is only known when the contract is called.
|
|
74
|
+
#
|
|
75
|
+
# The fallback Logger is built on every call, not memoized: #call!
|
|
76
|
+
# makes a new host per call, so an instance memo never applied, and a
|
|
77
|
+
# class-level one would capture $stderr once, hiding a later
|
|
78
|
+
# reassignment (a test's capture, a daemonized process's redirect).
|
|
79
|
+
def logger
|
|
80
|
+
return ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
81
|
+
|
|
82
|
+
::Logger.new($stderr)
|
|
83
|
+
end
|
|
84
|
+
|
|
65
85
|
# Instrumentation payload label (anonymous classes have no name).
|
|
66
86
|
def permittable_controller_name
|
|
67
87
|
"Permittable::Contract"
|
|
68
88
|
end
|
|
89
|
+
|
|
90
|
+
# ... and its action: the host has no action_name, so the payload's
|
|
91
|
+
# `action` (documented as a String) and the log lines that name the
|
|
92
|
+
# action would be nil. A Contract has one entry point, the same
|
|
93
|
+
# "call" #call! passes to permitted_params.
|
|
94
|
+
def permittable_action_name
|
|
95
|
+
ACTION
|
|
96
|
+
end
|
|
69
97
|
end
|
|
70
98
|
@host_class.permit_params(root: root, unknown: unknown, model: model, mode: :enforce, desc: desc, &)
|
|
71
99
|
end
|
|
@@ -118,9 +146,30 @@ module Permittable
|
|
|
118
146
|
|
|
119
147
|
def normalize_input(input)
|
|
120
148
|
return {} if input.nil?
|
|
121
|
-
return input if input.
|
|
149
|
+
return input if input.respond_to?(:to_unsafe_h)
|
|
150
|
+
return unwrap_parameters(input) if input.is_a?(Hash)
|
|
122
151
|
|
|
123
152
|
raise ArgumentError, "#{LABEL}: Contract#call expects a Hash (got #{input.class})"
|
|
124
153
|
end
|
|
154
|
+
|
|
155
|
+
# A controller's params reach the concern as ActionController::Parameters
|
|
156
|
+
# and are converted deeply by to_unsafe_h. A service object is usually
|
|
157
|
+
# handed a slice of them inside a plain Hash — `CreateUser.call(user:
|
|
158
|
+
# params[:user])` — and nothing converted those: a Parameters value is
|
|
159
|
+
# not a Hash, so a root read it as invalid_type (400), a nested block or
|
|
160
|
+
# :json field likewise (422). Each one is converted here, the way
|
|
161
|
+
# to_unsafe_h converts the whole. Only what holds one is copied.
|
|
162
|
+
def unwrap_parameters(value)
|
|
163
|
+
case value
|
|
164
|
+
when Hash
|
|
165
|
+
unwrapped = value.transform_values { |v| unwrap_parameters(v) }
|
|
166
|
+
unwrapped.each_value.zip(value.each_value).all? { |new, old| new.equal?(old) } ? value : unwrapped
|
|
167
|
+
when Array
|
|
168
|
+
unwrapped = value.map { |v| unwrap_parameters(v) }
|
|
169
|
+
unwrapped.zip(value).all? { |new, old| new.equal?(old) } ? value : unwrapped
|
|
170
|
+
else
|
|
171
|
+
value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h : value
|
|
172
|
+
end
|
|
173
|
+
end
|
|
125
174
|
end
|
|
126
175
|
end
|