permittable 0.9.0 → 0.11.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 +41 -0
- data/README.md +79 -18
- data/lib/permittable/audit.rb +231 -20
- data/lib/permittable/authored_values.rb +24 -8
- data/lib/permittable/generator.rb +69 -14
- data/lib/permittable/json_schema.rb +28 -4
- data/lib/permittable/open_api.rb +438 -80
- data/lib/permittable/tasks/generate.rake +9 -6
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +214 -49
- 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,13 +374,19 @@ 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
|
-
|
|
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
|
+
def format(entries, stale: {}, unmatched: unmatched_ignores(entries))
|
|
201
382
|
return "Permittable audit: no routed actions to report.\n" if entries.empty?
|
|
202
383
|
|
|
384
|
+
ignored, entries = entries.partition(&:ignored?)
|
|
203
385
|
out = entries.group_by(&:controller).map { |key, group| controller_block(key, group) }
|
|
204
386
|
out << summary_lines(summary(entries), rows: entries.length)
|
|
205
387
|
out << stale_lines(stale) unless stale.empty?
|
|
388
|
+
out << ignored_lines(ignored) unless ignored.empty?
|
|
389
|
+
out << unmatched_lines(unmatched) unless unmatched.empty?
|
|
206
390
|
"#{out.join("\n")}\n"
|
|
207
391
|
end
|
|
208
392
|
|
|
@@ -242,9 +426,14 @@ module Permittable
|
|
|
242
426
|
# say WHY they differ: an optional segment's variants are the usual
|
|
243
427
|
# reason, but two concatenated route lists sharing a controller, action
|
|
244
428
|
# and index collapse the same way.
|
|
429
|
+
#
|
|
430
|
+
# Each line agrees with its own count: "1 of those accepts", "1 covered
|
|
431
|
+
# action declares ... for it". `rows` needs no singular — it only prints
|
|
432
|
+
# when it exceeds the route count, so it is at least 2.
|
|
245
433
|
def summary_lines(counts, rows: counts[:actions])
|
|
246
434
|
body = counts[:uncovered_with_body]
|
|
247
435
|
missing = counts[:missing_actions]
|
|
436
|
+
models = counts[:unguarded_models]
|
|
248
437
|
# Its own bucket, so the head line still adds up to the route count.
|
|
249
438
|
not_found = ", #{missing} not found (Rails 404s #{missing == 1 ? 'it' : 'them'})" unless missing.zero?
|
|
250
439
|
listed = " in #{rows} rows" unless rows == counts[:actions]
|
|
@@ -253,9 +442,10 @@ module Permittable
|
|
|
253
442
|
"#{counts[:actions]} routed action#{'s' unless counts[:actions] == 1}#{listed}: " \
|
|
254
443
|
"#{counts[:enforced]} enforced, #{counts[:monitored]} in monitor mode, " \
|
|
255
444
|
"#{counts[:uncovered]} without a contract#{not_found}",
|
|
256
|
-
" #{body} of those accept
|
|
257
|
-
"
|
|
258
|
-
"
|
|
445
|
+
" #{body} of those accept#{'s' if body == 1} a request body" \
|
|
446
|
+
"#{' — untrusted input reaches the action unchecked' unless body.zero?}",
|
|
447
|
+
" #{models} covered action#{models == 1 ? ' declares' : 's declare'} no model:, " \
|
|
448
|
+
"so no schema-drift guard runs for #{models == 1 ? 'it' : 'them'}"
|
|
259
449
|
].join("\n")
|
|
260
450
|
end
|
|
261
451
|
|
|
@@ -264,5 +454,26 @@ module Permittable
|
|
|
264
454
|
"\nContracts declared for actions no route reaches or Rails would 404 (renamed or deleted?):\n" \
|
|
265
455
|
"#{pairs.map { |pair| " #{pair}" }.join("\n")}"
|
|
266
456
|
end
|
|
457
|
+
|
|
458
|
+
# One section per reason, in IGNORE_REASONS order, and one line per
|
|
459
|
+
# controller#action with every verb it is routed on: a `via: :all`
|
|
460
|
+
# catch-all is one line rather than five rows, so what was left out stays
|
|
461
|
+
# visible without crowding the table it was left out of.
|
|
462
|
+
def ignored_lines(ignored)
|
|
463
|
+
IGNORE_REASONS.filter_map do |reason, heading|
|
|
464
|
+
group = ignored.select { |entry| entry.ignored == reason }
|
|
465
|
+
next if group.empty?
|
|
466
|
+
|
|
467
|
+
verbs = group.group_by { |entry| "#{entry.controller}##{entry.action}" }
|
|
468
|
+
.transform_values { |list| list.map { |entry| entry.verb.to_s.upcase }.uniq.join(", ") }
|
|
469
|
+
width = verbs.keys.map(&:length).max
|
|
470
|
+
"\n#{heading}\n#{verbs.map { |pair, list| " #{pair.ljust(width)} #{list}" }.join("\n")}"
|
|
471
|
+
end.join("\n")
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def unmatched_lines(unmatched)
|
|
475
|
+
"\nPermittable.audit_ignore entries that match no routed action (a typo, or a route since removed?):\n" \
|
|
476
|
+
"#{unmatched.map { |item| " #{item}" }.join("\n")}"
|
|
477
|
+
end
|
|
267
478
|
end
|
|
268
479
|
end
|
|
@@ -3,12 +3,11 @@ module Permittable
|
|
|
3
3
|
# at class load — the same permittable_check_array a request goes
|
|
4
4
|
# through, so the two cannot drift apart. Two seams are overridden:
|
|
5
5
|
#
|
|
6
|
-
# *
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# already checked with.
|
|
6
|
+
# * the field WHOSE default:/example: is being validated does not run
|
|
7
|
+
# its own `transform:` — see permittable_transform below for exactly
|
|
8
|
+
# which field that is and why. A SUB-FIELD's own transform: still
|
|
9
|
+
# runs, so what gets stored is what an equivalent request would
|
|
10
|
+
# produce.
|
|
12
11
|
# * violations carry no `message:` — they become an ArgumentError for
|
|
13
12
|
# the contract's author, not a response for a client, and I18n may not
|
|
14
13
|
# be loaded yet.
|
|
@@ -26,6 +25,7 @@ module Permittable
|
|
|
26
25
|
# [value as a request would get it, violations]
|
|
27
26
|
def read_array(field, value)
|
|
28
27
|
violations = []
|
|
28
|
+
@field = field
|
|
29
29
|
read = permittable_check_array(field, value, path: field[:name].to_s, unknown: :ignore, violations: violations)
|
|
30
30
|
[read, violations]
|
|
31
31
|
end
|
|
@@ -36,8 +36,24 @@ module Permittable
|
|
|
36
36
|
|
|
37
37
|
private
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
# Suppresses transform: for the field WHOSE default/example is being
|
|
40
|
+
# authored-validated (@field, compared by identity) — never for a
|
|
41
|
+
# sub-field nested inside it. A sub-field's own transform: is app code
|
|
42
|
+
# too, but it belongs to a DIFFERENT field's contract: an equivalent
|
|
43
|
+
# request sending that sub-field's value would run it, so the stored
|
|
44
|
+
# default has to match, or an omitted field and an explicitly-sent
|
|
45
|
+
# identical value silently diverge (and the exported OpenAPI default,
|
|
46
|
+
# which reads this same value, documents one the server never produces).
|
|
47
|
+
#
|
|
48
|
+
# When @field itself has a transform:, its result is discarded anyway
|
|
49
|
+
# (validate_array_authored_value! stores the value AS AUTHORED instead —
|
|
50
|
+
# see its comment), so nothing inside its subtree is worth reading
|
|
51
|
+
# transformed: suppressing every nested call too keeps that walk free of
|
|
52
|
+
# app code, exactly as a scalar default's cast-only check already is.
|
|
53
|
+
def permittable_transform(field, value)
|
|
54
|
+
return value if @field[:transform] || field.equal?(@field)
|
|
55
|
+
|
|
56
|
+
super
|
|
41
57
|
end
|
|
42
58
|
|
|
43
59
|
def permittable_violation(_field, param, code)
|
|
@@ -297,33 +297,59 @@ module Permittable
|
|
|
297
297
|
# nil, until one drafts or none is left: a draft rooted at the other
|
|
298
298
|
# envelope in the file is a better starting point than no draft, and it
|
|
299
299
|
# is what master drafted.
|
|
300
|
-
|
|
300
|
+
#
|
|
301
|
+
# `except:` names actions of DEFAULT_ACTIONS to leave out of the draft —
|
|
302
|
+
# the ones Permittable.audit_ignore names (see targets). With none left
|
|
303
|
+
# there is nothing to draft.
|
|
304
|
+
def for_controller(controller, source: nil, model: nil, except: [])
|
|
305
|
+
return nil if (DEFAULT_ACTIONS - except.map(&:to_sym)).empty?
|
|
306
|
+
|
|
301
307
|
model ||= infer_model(controller)
|
|
302
308
|
excluded = []
|
|
303
309
|
loop do
|
|
304
310
|
scan = scan(source, model: model, exclude: excluded)
|
|
305
|
-
result = draft(model: model, scan: scan)
|
|
311
|
+
result = draft(model: model, scan: scan, except: except)
|
|
306
312
|
return result if result || !scan.found? || excluded.include?(scan.root)
|
|
307
313
|
|
|
308
314
|
excluded << scan.root
|
|
309
315
|
end
|
|
310
316
|
end
|
|
311
317
|
|
|
318
|
+
# What `permittable:generate` drafts when no controller is named, as
|
|
319
|
+
# { controller => actions to leave out of its draft }: every controller
|
|
320
|
+
# with no contract yet, less what the audit ignores (Audit.ignore_reason).
|
|
321
|
+
# ActiveStorage's direct uploads controller is the case this exists for —
|
|
322
|
+
# it was drafted, and a contract pasted into a gem's controller is not
|
|
323
|
+
# one the app can keep. A "controller#action" entry takes only that
|
|
324
|
+
# action out of the draft, and a controller with no drafted action left
|
|
325
|
+
# is skipped. Naming a controller to the task drafts it regardless, the
|
|
326
|
+
# way it drafts a covered one.
|
|
327
|
+
def targets(controllers, ignore: Permittable.audit_ignore, root: Audit.app_root)
|
|
328
|
+
controllers.each_with_object({}) do |controller, found|
|
|
329
|
+
next if controller.respond_to?(:permittable_contracts) && controller.permittable_contracts.any?
|
|
330
|
+
|
|
331
|
+
skipped = DEFAULT_ACTIONS.select { |action| Audit.ignore_reason(controller, action, ignore: ignore, root: root) }
|
|
332
|
+
found[controller] = skipped unless skipped == DEFAULT_ACTIONS
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
312
336
|
# The core: knowledge in (columns and/or a scan), snippet out. Returns a
|
|
313
337
|
# String of valid Ruby, or nil when neither source of knowledge exists —
|
|
314
338
|
# or when neither can declare a single field, since a contract of only
|
|
315
339
|
# TODO lines raises `a contract must declare at least one field` the
|
|
316
340
|
# moment it is pasted.
|
|
317
|
-
def draft(model: nil, scan: nil)
|
|
341
|
+
def draft(model: nil, scan: nil, except: [])
|
|
318
342
|
columns = columns_for(model)
|
|
319
343
|
scan = nil unless scan&.found?
|
|
320
344
|
return nil unless columns || scan
|
|
321
|
-
return column_draft(model, columns.values, []) unless scan
|
|
345
|
+
return column_draft(model, columns.values, [], except: except) unless scan
|
|
322
346
|
|
|
323
347
|
shared = scanned_lines(scan, listed_columns(columns, :shared))
|
|
324
|
-
return fallback_draft(model, scan, columns) unless declares_field?(shared)
|
|
348
|
+
return fallback_draft(model, scan, columns, except: except) unless declares_field?(shared)
|
|
325
349
|
|
|
326
|
-
render(root: scan.root, model: columns && model)
|
|
350
|
+
render(root: scan.root, model: columns && model, except: except) do |rule|
|
|
351
|
+
scanned_lines(scan, listed_columns(columns, rule))
|
|
352
|
+
end
|
|
327
353
|
end
|
|
328
354
|
|
|
329
355
|
# The columns as one rule drafts them (see DraftColumn#in_rule), each
|
|
@@ -356,7 +382,7 @@ module Permittable
|
|
|
356
382
|
# `permit(policy(@post).permitted_attributes)` the scanner skips — says
|
|
357
383
|
# nothing about the body, so it gets the model's root, as master drafted.
|
|
358
384
|
# With no columns either there is nothing loadable to draft, so nil.
|
|
359
|
-
def fallback_draft(model, scan, columns)
|
|
385
|
+
def fallback_draft(model, scan, columns, except: [])
|
|
360
386
|
return nil unless columns
|
|
361
387
|
|
|
362
388
|
root = scan.root || (rootless_body?(scan) ? nil : default_root(model))
|
|
@@ -365,7 +391,7 @@ module Permittable
|
|
|
365
391
|
# As in drop_drafted_todos: a rootless key the columns now declare is
|
|
366
392
|
# not also a "not in this contract" TODO.
|
|
367
393
|
scan = scan.dup.tap { |copy| copy.rootless = Array(scan.rootless).reject { |arg| drafted.key?(scalar_key(arg).to_s) } }
|
|
368
|
-
column_draft(model, drafted.values, scan_todo_lines(scan), root: root)
|
|
394
|
+
column_draft(model, drafted.values, scan_todo_lines(scan), root: root, except: except)
|
|
369
395
|
end
|
|
370
396
|
|
|
371
397
|
# Whether a rootless scan's calls carried at least one body field —
|
|
@@ -377,8 +403,10 @@ module Permittable
|
|
|
377
403
|
|
|
378
404
|
# A draft from the columns, or nil when none of them has a contract type
|
|
379
405
|
# to declare it with (render returns nil for a body of comments only).
|
|
380
|
-
def column_draft(model, columns, todos, root: default_root(model))
|
|
381
|
-
render(root: root, model: model)
|
|
406
|
+
def column_draft(model, columns, todos, root: default_root(model), except: [])
|
|
407
|
+
render(root: root, model: model, except: except) do |rule|
|
|
408
|
+
column_lines(columns.map { |column| column.in_rule(rule) }) + todos
|
|
409
|
+
end
|
|
382
410
|
end
|
|
383
411
|
|
|
384
412
|
def infer_model(controller)
|
|
@@ -400,6 +428,15 @@ module Permittable
|
|
|
400
428
|
# top (draft_column) must not share this rescue: an error there would
|
|
401
429
|
# otherwise discard EVERY column — the draft silently falls back to a
|
|
402
430
|
# scan alone, or to nothing — for what is one column's problem.
|
|
431
|
+
#
|
|
432
|
+
# The rescue is scoped to ActiveRecord::ActiveRecordError, same as
|
|
433
|
+
# ColumnGuard.schema_reachable? and for the same reason: a genuinely
|
|
434
|
+
# unreachable schema (no database yet, table not migrated) degrades to
|
|
435
|
+
# no columns, but a real bug — a broken custom type adapter, a NameError
|
|
436
|
+
# from a typo — must keep surfacing instead of quietly emitting an empty
|
|
437
|
+
# draft. The defined? guard keeps this gem loadable without
|
|
438
|
+
# activerecord, same as schema_reachable? (a host without it duck-types
|
|
439
|
+
# `model:` and cannot raise an ActiveRecordError in the first place).
|
|
403
440
|
def schema_columns(model)
|
|
404
441
|
return nil unless model.respond_to?(:columns)
|
|
405
442
|
return nil unless model.table_exists?
|
|
@@ -408,7 +445,9 @@ module Permittable
|
|
|
408
445
|
# into its column names; a nil primary key becomes [].
|
|
409
446
|
skipped = SKIPPED_COLUMNS + Array(model.primary_key).map(&:to_s)
|
|
410
447
|
model.columns.reject { |c| skipped.include?(c.name) }
|
|
411
|
-
rescue StandardError
|
|
448
|
+
rescue StandardError => e
|
|
449
|
+
raise unless defined?(ActiveRecord::ActiveRecordError) && e.is_a?(ActiveRecord::ActiveRecordError)
|
|
450
|
+
|
|
412
451
|
nil
|
|
413
452
|
end
|
|
414
453
|
|
|
@@ -507,7 +546,11 @@ module Permittable
|
|
|
507
546
|
# can be CALLED as `Model.name`: a `first-status` enum's
|
|
508
547
|
# `Order.first-statuses.keys` parses as `Order.first - statuses.keys`,
|
|
509
548
|
# which runs a query when the draft loads. defined_enums reaches the
|
|
510
|
-
# same mapping by name.
|
|
549
|
+
# same mapping by name. The model is also checked for the accessor
|
|
550
|
+
# itself (mirrors ColumnGuard.enum_keys_expr): a name can pluralize to a
|
|
551
|
+
# valid identifier that the model does not actually answer to — renamed
|
|
552
|
+
# or otherwise excluded — and calling it would raise NoMethodError when
|
|
553
|
+
# the draft loads.
|
|
511
554
|
def enum_for(model, name)
|
|
512
555
|
return nil unless model.respond_to?(:defined_enums)
|
|
513
556
|
|
|
@@ -515,7 +558,11 @@ module Permittable
|
|
|
515
558
|
return nil unless mapping
|
|
516
559
|
|
|
517
560
|
plural = name.pluralize
|
|
518
|
-
accessor = METHOD_NAME.match?(plural)
|
|
561
|
+
accessor = if METHOD_NAME.match?(plural) && model.respond_to?(plural)
|
|
562
|
+
"#{model.name}.#{plural}"
|
|
563
|
+
else
|
|
564
|
+
"#{model.name}.defined_enums[#{name.inspect}]"
|
|
565
|
+
end
|
|
519
566
|
{ mapping: mapping, accessor: accessor }
|
|
520
567
|
end
|
|
521
568
|
|
|
@@ -986,12 +1033,20 @@ module Permittable
|
|
|
986
1033
|
# no field, which raises `a contract must declare at least one field`
|
|
987
1034
|
# when pasted. There is then nothing loadable to draft: nil, as for no
|
|
988
1035
|
# knowledge at all, which the rake task already skips.
|
|
989
|
-
|
|
1036
|
+
#
|
|
1037
|
+
# An action in `except` is dropped from the rule that declares it, and a
|
|
1038
|
+
# rule left with no action is dropped: excepting :create from a split
|
|
1039
|
+
# draft leaves the :update rule as drafted, with nothing required.
|
|
1040
|
+
def render(root:, model:, except: [], &lines)
|
|
990
1041
|
shared = lines.call(:shared)
|
|
991
1042
|
return nil if shared.all? { |line| line.start_with?("#") }
|
|
992
1043
|
|
|
993
1044
|
update = lines.call(:update)
|
|
994
1045
|
rules = shared == update ? [[DEFAULT_ACTIONS, shared]] : [[%i[create], lines.call(:create)], [%i[update], update]]
|
|
1046
|
+
skipped = except.map(&:to_sym)
|
|
1047
|
+
rules = rules.filter_map { |actions, body| [actions - skipped, body] unless (actions - skipped).empty? }
|
|
1048
|
+
return nil if rules.empty?
|
|
1049
|
+
|
|
995
1050
|
bodies = rules.map do |actions, body|
|
|
996
1051
|
"#{signature(root: root, model: model, actions: actions)}\n#{body.map { |line| " #{line}\n" }.join}end\n"
|
|
997
1052
|
end
|
|
@@ -114,8 +114,16 @@ module Permittable
|
|
|
114
114
|
schema
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# deep_dup, not dup: .freeze is shallow, so the Array nested in an entry
|
|
118
|
+
# like :decimal ("type" => %w[string number]) stays live inside the frozen
|
|
119
|
+
# top-level Hash, and a shallow .dup would hand every :decimal field's
|
|
120
|
+
# exported schema that SAME Array. Nothing in this file mutates it in
|
|
121
|
+
# place (nullify! rebinds "type" to a new Array), but the exported
|
|
122
|
+
# document is caller-owned data, and a caller appending to it would
|
|
123
|
+
# otherwise silently rewrite the constant for every schema exported
|
|
124
|
+
# afterward in the process.
|
|
117
125
|
def scalar_schema(field)
|
|
118
|
-
schema = SCALAR_SCHEMAS.fetch(field[:type]).
|
|
126
|
+
schema = SCALAR_SCHEMAS.fetch(field[:type]).deep_dup
|
|
119
127
|
apply_format_name!(schema, field)
|
|
120
128
|
apply_in!(schema, field)
|
|
121
129
|
apply_string_bounds!(schema, field)
|
|
@@ -151,7 +159,11 @@ module Permittable
|
|
|
151
159
|
min, max = length_bounds(field[:length])
|
|
152
160
|
schema["minItems"] = min if min
|
|
153
161
|
schema["maxItems"] = max if max
|
|
154
|
-
|
|
162
|
+
# deep_dup here for the same reason as scalar_schema: a scalar `of:`
|
|
163
|
+
# otherwise hands every array field the SAME nested Array/Hash from
|
|
164
|
+
# SCALAR_SCHEMAS.
|
|
165
|
+
schema["items"] =
|
|
166
|
+
field[:fields] ? object(field[:fields], unknown: unknown) : SCALAR_SCHEMAS.fetch(field[:of]).deep_dup
|
|
155
167
|
schema
|
|
156
168
|
end
|
|
157
169
|
|
|
@@ -343,10 +355,22 @@ module Permittable
|
|
|
343
355
|
# (silently changing a value outside any :decimal schema's justification)
|
|
344
356
|
# or coerced through Float, where a non-finite BigDecimal (Infinity, NaN)
|
|
345
357
|
# would crash JSON.generate.
|
|
358
|
+
#
|
|
359
|
+
# A `sensitive:` field's `default:`/`example:` are OMITTED rather than
|
|
360
|
+
# published: this exported document is the one channel meant to leave
|
|
361
|
+
# the app (client-generator tooling, a public docs endpoint), unlike the
|
|
362
|
+
# request log a `sensitive:` value is otherwise only redacted from, so
|
|
363
|
+
# shipping the real value here would defeat the redaction entirely.
|
|
364
|
+
# Omitting — rather than a placeholder string — matches how every other
|
|
365
|
+
# untranslatable or opaque fact in this file is handled: left out, with
|
|
366
|
+
# the `x-permittable-*` extension (here, `writeOnly`/`x-permittable-
|
|
367
|
+
# sensitive`) as the only signal that something is missing.
|
|
346
368
|
def annotate(schema, field)
|
|
347
369
|
decimal_mode = field[:kind] == JSON_TYPE ? :string : :number
|
|
348
|
-
|
|
349
|
-
|
|
370
|
+
unless field[:sensitive]
|
|
371
|
+
schema["default"] = json_value(field[:default], decimal: decimal_mode) if field.key?(:default)
|
|
372
|
+
schema["examples"] = [json_value(field[:example], decimal: decimal_mode)] if field.key?(:example)
|
|
373
|
+
end
|
|
350
374
|
schema["description"] = field[:desc] if field[:desc]
|
|
351
375
|
if field[:sensitive]
|
|
352
376
|
schema["writeOnly"] = true
|