permittable 0.10.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.
@@ -3,7 +3,8 @@
3
3
  # params.expect calls in its source. Drafts go to stdout (paste-ready); the
4
4
  # summary goes to stderr.
5
5
  #
6
- # bin/rails permittable:generate # every uncovered controller
6
+ # bin/rails permittable:generate # every uncovered controller not
7
+ # # left out by Permittable.audit_ignore
7
8
  # bin/rails "permittable:generate[UsersController]" # one controller, even if covered
8
9
  namespace :permittable do
9
10
  desc "Draft Permittable contracts from models and existing permit calls"
@@ -15,23 +16,25 @@ namespace :permittable do
15
16
  bases << ActionController::API if defined?(ActionController::API)
16
17
  controllers = bases.flat_map(&:descendants).uniq.select(&:name)
17
18
 
19
+ # { controller => actions to leave out of its draft }. A named controller
20
+ # is drafted whole, even if covered or ignored; the sweep skips both, and
21
+ # leaves out what Permittable.audit_ignore names (see Generator.targets).
18
22
  if task_args[:controller]
19
23
  controllers = controllers.select { |c| c.name == task_args[:controller] }
20
24
  abort "Permittable: no controller named #{task_args[:controller]} was found" if controllers.empty?
25
+ targets = controllers.to_h { |c| [c, []] }
21
26
  else
22
- controllers = controllers.reject do |c|
23
- c.respond_to?(:permittable_contracts) && c.permittable_contracts.any?
24
- end
27
+ targets = Permittable::Generator.targets(controllers)
25
28
  end
26
29
 
27
- drafted = controllers.sort_by(&:name).count do |controller|
30
+ drafted = targets.sort_by { |controller, _| controller.name }.count do |controller, except|
28
31
  path = begin
29
32
  Object.const_source_location(controller.name)&.first
30
33
  rescue StandardError
31
34
  nil
32
35
  end
33
36
  source = path && File.exist?(path) ? File.read(path) : nil
34
- snippet = Permittable::Generator.for_controller(controller, source: source)
37
+ snippet = Permittable::Generator.for_controller(controller, source: source, except: except)
35
38
  next false unless snippet
36
39
 
37
40
  puts ["# ====", controller.name, path && "(#{path})", "===="].compact.join(" ")
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.10.0".freeze
2
+ VERSION = "0.11.0".freeze
3
3
  end
data/lib/permittable.rb CHANGED
@@ -218,6 +218,10 @@ module Permittable
218
218
  UNKNOWN_MODES = %i[ignore log error].freeze
219
219
  MODES = %i[enforce monitor].freeze
220
220
  ERROR_FORMATS = %i[envelope problem].freeze
221
+ # A Permittable.audit_ignore entry: a controller path the way Rails names
222
+ # it (lowercase, `/`-separated — "active_storage/direct_uploads"),
223
+ # optionally followed by "#action".
224
+ AUDIT_IGNORE_ENTRY = %r{\A[a-z0-9_]+(?:/[a-z0-9_]+)*(?:#\w+)?\z}
221
225
  # Rails merges routing bookkeeping into params; a top-level (root: false)
222
226
  # unknown-keys check must not flag them.
223
227
  ROUTING_KEYS = %w[controller action format].freeze
@@ -238,7 +242,7 @@ module Permittable
238
242
  MONITOR_DROPPED_KEYS = ROUTING_KEYS
239
243
  # The field kinds that can hold ParamsWrapper's copy of a body — a hash. A
240
244
  # rootless contract declaring the wrapper key as one of these reads the
241
- # copy deliberately, so it is kept (see permittable_without_wrapper_copy).
245
+ # copy deliberately, so it is kept (see permittable_drops_wrapper_copy?).
242
246
  WRAPPER_CONTAINER_KINDS = [:nested, JSON_TYPE].freeze
243
247
  # A log line and an exception message are PROSE, written for a person. They
244
248
  # list at most this many names and count the rest, so one request cannot
@@ -512,6 +516,55 @@ module Permittable
512
516
  @check_column_types = value
513
517
  end
514
518
 
519
+ # Routed actions that `permittable:audit` leaves out of its counts and of
520
+ # `[strict]`, and that `permittable:generate` does not draft for — the
521
+ # endpoints that are not the app's to guard:
522
+ #
523
+ # Permittable.audit_ignore = %w[active_storage/direct_uploads application#not_found]
524
+ #
525
+ # Each entry is a controller path as Rails routes it (every action of that
526
+ # controller) or "controller#action" (that one action, on every verb — a
527
+ # `via: :all` catch-all is one entry). Ignored actions are still listed,
528
+ # in a section of their own, and an entry that matches no routed action is
529
+ # listed too: an ignore list nothing checks would hide its own typos.
530
+ #
531
+ # Strings only, checked here. A class name is the likeliest mistake and
532
+ # the error names the path meant; a Class is refused because naming one
533
+ # in an initializer autoloads it at boot, which Zeitwerk refuses for a
534
+ # reloadable controller; a Regexp is refused because a pattern would also
535
+ # ignore controllers written AFTER it, and catching a new unguarded
536
+ # endpoint is what the gate is for. Validated as a whole before anything
537
+ # is stored, so a rejected list leaves the previous one in place. nil
538
+ # clears it.
539
+ def audit_ignore
540
+ @audit_ignore || [].freeze
541
+ end
542
+
543
+ def audit_ignore=(entries)
544
+ entries = Array(entries).map { |entry| audit_ignore_entry(entry) }
545
+ @audit_ignore = entries.uniq.freeze
546
+ end
547
+
548
+ # Whether the audit and the generator also leave out every controller
549
+ # whose source is outside Rails.root — engines and gems: ActiveStorage,
550
+ # ActionMailbox, Rails's own rails/* controllers. On by default, since an
551
+ # app cannot add a contract to a gem's controller; those are listed in
552
+ # the report under their own reason rather than dropped. Turn it off to
553
+ # audit them like the app's own:
554
+ #
555
+ # Permittable.audit_ignore_outside_root = false
556
+ #
557
+ # See Audit.outside_root? for how a controller is located.
558
+ def audit_ignore_outside_root
559
+ @audit_ignore_outside_root.nil? || @audit_ignore_outside_root
560
+ end
561
+
562
+ def audit_ignore_outside_root=(value)
563
+ raise ArgumentError, "#{LABEL}: audit_ignore_outside_root must be true or false" unless [true, false].include?(value)
564
+
565
+ @audit_ignore_outside_root = value
566
+ end
567
+
515
568
  # App-wide fallback copy for a violation code, looked up through I18n
516
569
  # under permittable.errors.<code> ("missing", "inclusion", or any Symbol
517
570
  # a validate: returned). Consulted only when the field declares no
@@ -526,6 +579,24 @@ module Permittable
526
579
  message = ::I18n.t("permittable.errors.#{code}", default: nil)
527
580
  message.is_a?(String) ? message : nil
528
581
  end
582
+
583
+ private
584
+
585
+ # One audit_ignore entry, frozen, or an ArgumentError that says what an
586
+ # entry looks like — and, for a class or a class name, which path it
587
+ # meant: `ActiveStorage::DirectUploadsController` is the path
588
+ # "active_storage/direct_uploads", the same derivation Rails's own
589
+ # controller_path makes.
590
+ def audit_ignore_entry(entry)
591
+ return entry.dup.freeze if entry.is_a?(String) && AUDIT_IGNORE_ENTRY.match?(entry)
592
+
593
+ meant = entry.controller_path if entry.is_a?(Class) && entry.respond_to?(:controller_path)
594
+ meant ||= entry.underscore.delete_suffix("_controller") if entry.is_a?(String) && entry.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
595
+ raise ArgumentError,
596
+ "#{LABEL}: audit_ignore takes controller paths as Rails routes them (\"active_storage/direct_uploads\") " \
597
+ "or \"controller#action\" (\"application#not_found\"), got #{entry.inspect}" \
598
+ "#{" — did you mean #{meant.inspect}?" if meant}"
599
+ end
529
600
  end
530
601
 
531
602
  # Raised when the request violates the matching contract. `details` is an
@@ -1891,38 +1962,74 @@ module Permittable
1891
1962
  )
1892
1963
  end
1893
1964
 
1894
- private
1895
-
1896
1965
  # ParamsWrapper copies a JSON body under the controller's wrapper key
1897
1966
  # (`user` for UsersController) — on by default in a Rails app — and a
1898
1967
  # rootless contract then saw that copy as an unknown top-level key on every
1899
- # well-formed request. Whether the copy is Rails' own has to be read HERE,
1900
- # before ParamsWrapper#process_action (next in the chain) runs: once it has
1901
- # wrapped, `_wrapper_enabled?` answers false, because params now carry the
1902
- # key. Asking afterwards could not tell Rails' copy from a client that sent
1903
- # `user` itself, which is exactly the key the check must still flag.
1904
- # Private, like the method it wraps — a public one would become an action.
1905
- # A plain duck has no process_action and no ParamsWrapper, so this never
1906
- # runs there, and the guards keep an actionpack-free host inert.
1968
+ # well-formed request. Whether the copy is Rails' own has to be read before
1969
+ # ParamsWrapper#process_action wraps: afterwards `_wrapper_enabled?` answers
1970
+ # false, because params now carry the key, and nothing left in the request
1971
+ # tells Rails' copy from a client that sent `user` itself, which is exactly
1972
+ # the key the check must still flag. (Comparing the key's value with what
1973
+ # the wrapper would have built only guesses: a client can send that value.)
1974
+ #
1975
+ # So it is read here, in `process`, not in process_action. Every
1976
+ # process_action, ParamsWrapper's included, runs inside
1977
+ # AbstractController::Base#process, so no module order can put the
1978
+ # wrapping first. A process_action hook relied on Rails' own order:
1979
+ # ActionController::Base includes ParamsWrapper, so an
1980
+ # ApplicationController's Permittable runs before it. A Metal controller
1981
+ # that includes ParamsWrapper AFTER Permittable (in a subclass, say) ran
1982
+ # ParamsWrapper's process_action first, and every wrapped JSON request was
1983
+ # a 422 again.
1984
+ #
1985
+ # Public, like the method it wraps, so a caller that sends `process`
1986
+ # explicitly still can. It never becomes an action:
1987
+ # AbstractController::Base#process is a public method of every abstract
1988
+ # controller class (Metal, Base, API), and action_methods leaves those out
1989
+ # as internal. On a plain duck, which has no ParamsWrapper, the guards
1990
+ # make it a pass-through to whatever `process` the host has.
1907
1991
  # Assigned on every request, never only when true: a controller instance
1908
1992
  # dispatched twice would otherwise carry one request's exemption into the
1909
1993
  # next, where a `user` the client did send would pass as Rails' copy.
1910
- def process_action(*)
1994
+ def process(...)
1911
1995
  @permittable_wrapper_key = permittable_wrapper_copy_key
1912
1996
  super
1913
1997
  end
1914
1998
 
1915
- # The wrapper key, if ParamsWrapper is about to copy the body under it;
1916
- # nil otherwise. `_wrapper_enabled?` alone is not enough: it asks the
1917
- # string-keyed params for the key AS CONFIGURED, so `wrap_parameters :user`
1918
- # — the form the Rails docs use — answers "not sent" even when the client
1919
- # sent `user` itself, and Rails wraps anyway. Asking the same params for
1920
- # the String keeps that client's key the client's, whichever spelling the
1921
- # host chose.
1999
+ private
2000
+
2001
+ # The wrapper key, if ParamsWrapper is about to copy the body under it and
2002
+ # a contract on this controller would act on that; nil otherwise.
2003
+ #
2004
+ # The request is asked only when the answer can matter. A controller none
2005
+ # of whose contracts reads its input without the copy (see
2006
+ # permittable_drops_wrapper_copy?) — only rooted contracts, say, or the
2007
+ # default `unknown: :ignore` with no field of the wrapper's name — paid a
2008
+ # content-type lookup and a params read on every request for nothing.
2009
+ # Everything the gate reads is class-level (the wrapper's formats and name,
2010
+ # and the contracts), so a skipped probe touches nothing of the request.
2011
+ # The formats come first, in ParamsWrapper's own order: with none it never
2012
+ # wraps and never asks for its name — which it could not give, an
2013
+ # unconfigured wrapper having no controller to derive one from. The
2014
+ # contract gate is ALL of the controller's contracts, not only the one
2015
+ # covering this action: permitted_params(:create) can read another
2016
+ # action's contract from inside #update, and by then ParamsWrapper has
2017
+ # wrapped and the question can no longer be answered.
2018
+ #
2019
+ # `_wrapper_enabled?` asks the params for the key as configured. Rails'
2020
+ # params are indifferent on every supported version, so that already
2021
+ # covers `wrap_parameters :user` and "user" alike. Asking again for the
2022
+ # String keeps a client's `user` the client's on a plain string-keyed
2023
+ # params Hash too — one a test seeds into the request directly — where
2024
+ # :user would answer "not sent" and ParamsWrapper would wrap anyway.
1922
2025
  def permittable_wrapper_copy_key
1923
- return unless respond_to?(:_wrapper_enabled?, true) && respond_to?(:_wrapper_key, true) && _wrapper_enabled?
2026
+ return unless %i[_wrapper_enabled? _wrapper_formats _wrapper_key].all? { |name| respond_to?(name, true) }
2027
+ return if _wrapper_formats.empty?
2028
+
2029
+ key = _wrapper_key&.to_s
2030
+ return unless key && self.class.permittable_contracts.any? { |rule| permittable_drops_wrapper_copy?(rule, key) }
2031
+ return unless _wrapper_enabled?
1924
2032
 
1925
- key = _wrapper_key.to_s
1926
2033
  key unless request.parameters.key?(key)
1927
2034
  end
1928
2035
 
@@ -1944,7 +2051,7 @@ module Permittable
1944
2051
  source = permittable_root_hash(rule, violations)
1945
2052
  result = ActiveSupport::HashWithIndifferentAccess.new
1946
2053
  if source
1947
- checked = rule[:root] ? source : permittable_without_wrapper_copy(rule[:fields], source)
2054
+ checked = permittable_without_wrapper_copy(rule, source)
1948
2055
  result = permittable_check_hash(rule[:fields], checked, path: rule[:root] ? rule[:root].to_s : nil,
1949
2056
  unknown: rule[:unknown], top_level: !rule[:root], violations: violations)
1950
2057
  end
@@ -2429,28 +2536,39 @@ module Permittable
2429
2536
  token && token.to_s
2430
2537
  end
2431
2538
 
2432
- # A rootless contract's input without ParamsWrapper's copy of the body,
2433
- # when Rails made one (see process_action). Removed rather than merely
2434
- # exempted from the unknown-keys check, because the client never sent that
2435
- # key: a contract that happens to declare a scalar or array field of the
2539
+ # A contract's input without ParamsWrapper's copy of the body, when Rails
2540
+ # made one (see process) and the contract reads its input without it (see
2541
+ # permittable_drops_wrapper_copy?). Removed rather than merely exempted
2542
+ # from the unknown-keys check, because the client never sent that key: a
2543
+ # contract that happens to declare a scalar or array field of the
2436
2544
  # wrapper's name (`optional :feedback, :string` on FeedbackController)
2437
2545
  # would otherwise validate Rails' copy of the whole body as that field — a
2438
- # false 422 invalid_type for a well-formed request.
2439
- #
2440
- # Kept, though, when the contract declares that key as a hash container (a
2441
- # nested block or :json): that rootless contract is reading the copy ON
2442
- # PURPOSE, a root: spelled as a field, and it worked that way before the
2443
- # copy was ever dropped — dropping it would turn every such request into
2444
- # `user missing`. Top level only, where the copy lives; a rooted contract
2445
- # reads the copy as its root, which is exactly what ParamsWrapper is for.
2446
- # What is checked changes, not what monitor mode hands back: its raw
2447
- # pass-through still carries the copy, as the pre-contract app's params did.
2448
- def permittable_without_wrapper_copy(fields, source)
2546
+ # false 422 invalid_type for a well-formed request. Top level only, where
2547
+ # the copy lives. What is checked changes, not what monitor mode hands
2548
+ # back: its raw pass-through still carries the copy, as the pre-contract
2549
+ # app's params did.
2550
+ def permittable_without_wrapper_copy(rule, source)
2449
2551
  key = @permittable_wrapper_key
2450
- return source unless key
2451
- return source if fields.any? { |f| f[:name].to_s == key && WRAPPER_CONTAINER_KINDS.include?(f[:kind]) }
2552
+ key && permittable_drops_wrapper_copy?(rule, key) ? source.except(key) : source
2553
+ end
2452
2554
 
2453
- source.except(key)
2555
+ # Whether `rule` reads its input without ParamsWrapper's copy under `key`
2556
+ # — and so whether the probe's answer changes anything for it, which is
2557
+ # why permittable_wrapper_copy_key asks this too before touching the
2558
+ # request. Not for a rooted contract: it reads the copy as its root, which
2559
+ # is exactly what ParamsWrapper is for. Not for a rootless one declaring
2560
+ # `key` as a hash container (a nested block or :json) either: that
2561
+ # contract is reading the copy ON PURPOSE, a root: spelled as a field, and
2562
+ # it worked that way before the copy was ever dropped — dropping it would
2563
+ # turn every such request into `user missing`. Any other field of that
2564
+ # name would validate the copy, so the copy goes whatever the unknown:
2565
+ # mode. Undeclared, it would only be flagged as unknown, which
2566
+ # `unknown: :ignore` never does, so there the copy changes nothing.
2567
+ def permittable_drops_wrapper_copy?(rule, key)
2568
+ return false if rule[:root]
2569
+
2570
+ field = rule[:fields].find { |f| f[:name].to_s == key }
2571
+ field ? !WRAPPER_CONTAINER_KINDS.include?(field[:kind]) : rule[:unknown] != :ignore
2454
2572
  end
2455
2573
 
2456
2574
  def permittable_path(path, key)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: permittable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-29 00:00:00.000000000 Z
11
+ date: 2026-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport