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.
@@ -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
- Entry = Struct.new(:controller, :action, :verb, :path, :rule, :missing_action, :route, keyword_init: true) do
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
- def entries(controllers:, routes:)
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
- def controller_entries(controller, routes)
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, and each resolver call builds a controller.
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
- controllers.each_with_object({}) do |controller, found|
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
- key = OpenAPI.controller_key(controller) || controller.inspect
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
- def format(entries, stale: {})
201
- return "Permittable audit: no routed actions to report.\n" if entries.empty?
202
-
203
- out = entries.group_by(&:controller).map { |key, group| controller_block(key, group) }
204
- out << summary_lines(summary(entries), rows: entries.length)
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 a request body#{' — untrusted input reaches the action unchecked' unless body.zero?}",
257
- " #{counts[:unguarded_models]} covered action#{'s' unless counts[:unguarded_models] == 1} " \
258
- "declare no model:, so no schema-drift guard runs for them"
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:, 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.
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 @field[:transform] || field.equal?(@field)
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; only the enum rule reads it.
37
- def ensure_columns_on!(label, klass, *fields, types: nil, check_types: false, allowed: nil)
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
- return ensure_enum_contract!(label, klass, field, declared, allowed && allowed[field.to_sym]) if enum && wanted == :text
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. A
111
- # Range, or anything else without a finite list, cannot be checked, so it
112
- # is refused rather than trusted.
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
- stray = listed.to_a - enum_values(klass, field)
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).
@@ -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.is_a?(Hash) || input.respond_to?(:to_unsafe_h)
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