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.
@@ -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.9.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
@@ -403,7 +407,14 @@ module Permittable
403
407
  # replay of #names — otherwise a sink deduplicating by value would hold
404
408
  # both :ssn and "ssn".
405
409
  name = name.to_s.downcase
406
- sensitive_parameter_sinks.each { |sink| sink.call(name) } unless name.empty?
410
+ unless name.empty?
411
+ # sensitive_parameter_sinks is the same process-global Array
412
+ # on_sensitive_parameter appends to under @registry_mutex. Reading it
413
+ # here without that lock is an unsynchronized concurrent mutation
414
+ # during iteration on any Ruby without a GVL — a thread class-loading
415
+ # a sensitive: true contract can race a thread installing a sink.
416
+ @registry_mutex.synchronize { sensitive_parameter_sinks.dup }.each { |sink| sink.call(name) }
417
+ end
407
418
  nil
408
419
  end
409
420
 
@@ -505,6 +516,55 @@ module Permittable
505
516
  @check_column_types = value
506
517
  end
507
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
+
508
568
  # App-wide fallback copy for a violation code, looked up through I18n
509
569
  # under permittable.errors.<code> ("missing", "inclusion", or any Symbol
510
570
  # a validate: returned). Consulted only when the field declares no
@@ -519,6 +579,24 @@ module Permittable
519
579
  message = ::I18n.t("permittable.errors.#{code}", default: nil)
520
580
  message.is_a?(String) ? message : nil
521
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
522
600
  end
523
601
 
524
602
  # Raised when the request violates the matching contract. `details` is an
@@ -744,6 +822,17 @@ module Permittable
744
822
  end
745
823
  end
746
824
 
825
+ # Kernel#Integer/Float and BigDecimal() all accept underscore digit
826
+ # separators and surrounding whitespace — a convenience for a NUMBER
827
+ # LITERAL IN RUBY SOURCE, not for a request body. "1_8" is not how a
828
+ # client spells eighteen, and " 99 " is not how one spells ninety-nine;
829
+ # silently accepting either is the same kind of leniency as the
830
+ # NaN/Infinity/`0e10` cases below, just arriving from a different door.
831
+ # Checked against the raw String before any of those delegate, so a
832
+ # non-canonical spelling never reaches them at all.
833
+ INTEGER_FORMAT = /\A[+-]?\d+\z/
834
+ NUMERIC_FORMAT = /\A[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?\z/
835
+
747
836
  def cast_integer(value)
748
837
  case value
749
838
  when Integer then [:ok, value]
@@ -751,7 +840,7 @@ module Permittable
751
840
  # RangeError, which the ArgumentError rescue below does not catch), and
752
841
  # no integer is what either one sent. Same rule as finite_float.
753
842
  when Float then value.finite? && value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"]
754
- when String then [:ok, Integer(value, 10)]
843
+ when String then value.match?(INTEGER_FORMAT) ? [:ok, Integer(value, 10)] : [:error, "invalid_type"]
755
844
  else [:error, "invalid_type"]
756
845
  end
757
846
  rescue ArgumentError
@@ -761,7 +850,7 @@ module Permittable
761
850
  def cast_float(value)
762
851
  case value
763
852
  when Numeric then finite_float(value.to_f)
764
- when String then finite_float(Float(value), source: value)
853
+ when String then value.match?(NUMERIC_FORMAT) ? finite_float(Float(value), source: value) : [:error, "invalid_type"]
765
854
  else [:error, "invalid_type"]
766
855
  end
767
856
  rescue ArgumentError
@@ -792,7 +881,8 @@ module Permittable
792
881
 
793
882
  def cast_decimal(value)
794
883
  case value
795
- when Numeric, String then finite_decimal(BigDecimal(value.to_s))
884
+ when Numeric then finite_decimal(BigDecimal(value.to_s))
885
+ when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"]
796
886
  else [:error, "invalid_type"]
797
887
  end
798
888
  rescue ArgumentError
@@ -1561,8 +1651,10 @@ module Permittable
1561
1651
  # gets); with one, the array exactly AS AUTHORED — the walker still runs,
1562
1652
  # so a declaration mistake (an element `validate:` refuses, a sub-field
1563
1653
  # default out of bounds) still fails at class load, but its cast result
1564
- # is discarded rather than stored. `transform:` itself is deliberately
1565
- # never run on a default either way — see AuthoredValues.
1654
+ # is discarded rather than stored. The array's OWN `transform:` is
1655
+ # deliberately never run on a default either way; a SUB-FIELD's
1656
+ # `transform:` still runs during that walk, so "the walker's read" above
1657
+ # really is what an equivalent request produces — see AuthoredValues.
1566
1658
  def validate_array_authored_value!(field, opt)
1567
1659
  value = field[opt]
1568
1660
  return if authored_nil!(field, opt)
@@ -1621,7 +1713,11 @@ module Permittable
1621
1713
  def validate_message!(field)
1622
1714
  spec = field[:message]
1623
1715
  return if spec.nil?
1624
- return if spec.is_a?(String)
1716
+
1717
+ if spec.is_a?(String)
1718
+ field[:message] = freeze_authored(spec)
1719
+ return
1720
+ end
1625
1721
 
1626
1722
  valid_hash = spec.is_a?(Hash) && !spec.empty? &&
1627
1723
  spec.all? { |code, text| (code.is_a?(Symbol) || code.is_a?(String)) && text.is_a?(String) }
@@ -1630,7 +1726,7 @@ module Permittable
1630
1726
  "or a Hash of violation code => String (e.g. { missing: \"is required\" })"
1631
1727
  end
1632
1728
 
1633
- field[:message] = spec.transform_keys(&:to_sym).freeze
1729
+ field[:message] = freeze_authored(spec.transform_keys(&:to_sym))
1634
1730
  end
1635
1731
  end
1636
1732
 
@@ -1866,38 +1962,74 @@ module Permittable
1866
1962
  )
1867
1963
  end
1868
1964
 
1869
- private
1870
-
1871
1965
  # ParamsWrapper copies a JSON body under the controller's wrapper key
1872
1966
  # (`user` for UsersController) — on by default in a Rails app — and a
1873
1967
  # rootless contract then saw that copy as an unknown top-level key on every
1874
- # well-formed request. Whether the copy is Rails' own has to be read HERE,
1875
- # before ParamsWrapper#process_action (next in the chain) runs: once it has
1876
- # wrapped, `_wrapper_enabled?` answers false, because params now carry the
1877
- # key. Asking afterwards could not tell Rails' copy from a client that sent
1878
- # `user` itself, which is exactly the key the check must still flag.
1879
- # Private, like the method it wraps — a public one would become an action.
1880
- # A plain duck has no process_action and no ParamsWrapper, so this never
1881
- # 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.
1882
1991
  # Assigned on every request, never only when true: a controller instance
1883
1992
  # dispatched twice would otherwise carry one request's exemption into the
1884
1993
  # next, where a `user` the client did send would pass as Rails' copy.
1885
- def process_action(*)
1994
+ def process(...)
1886
1995
  @permittable_wrapper_key = permittable_wrapper_copy_key
1887
1996
  super
1888
1997
  end
1889
1998
 
1890
- # The wrapper key, if ParamsWrapper is about to copy the body under it;
1891
- # nil otherwise. `_wrapper_enabled?` alone is not enough: it asks the
1892
- # string-keyed params for the key AS CONFIGURED, so `wrap_parameters :user`
1893
- # — the form the Rails docs use — answers "not sent" even when the client
1894
- # sent `user` itself, and Rails wraps anyway. Asking the same params for
1895
- # the String keeps that client's key the client's, whichever spelling the
1896
- # 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.
1897
2025
  def permittable_wrapper_copy_key
1898
- 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?
1899
2032
 
1900
- key = _wrapper_key.to_s
1901
2033
  key unless request.parameters.key?(key)
1902
2034
  end
1903
2035
 
@@ -1919,7 +2051,7 @@ module Permittable
1919
2051
  source = permittable_root_hash(rule, violations)
1920
2052
  result = ActiveSupport::HashWithIndifferentAccess.new
1921
2053
  if source
1922
- checked = rule[:root] ? source : permittable_without_wrapper_copy(rule[:fields], source)
2054
+ checked = permittable_without_wrapper_copy(rule, source)
1923
2055
  result = permittable_check_hash(rule[:fields], checked, path: rule[:root] ? rule[:root].to_s : nil,
1924
2056
  unknown: rule[:unknown], top_level: !rule[:root], violations: violations)
1925
2057
  end
@@ -2357,7 +2489,10 @@ module Permittable
2357
2489
 
2358
2490
  declared = fields.map { |f| f[:name].to_s }
2359
2491
  extra = hash.keys.map(&:to_s) - declared
2360
- extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys if top_level
2492
+ if top_level
2493
+ extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys
2494
+ extra -= [permittable_configured_csrf_key].compact
2495
+ end
2361
2496
  return if extra.empty?
2362
2497
 
2363
2498
  if unknown == :error
@@ -2382,28 +2517,58 @@ module Permittable
2382
2517
  request.path_parameters.keys.map(&:to_s)
2383
2518
  end
2384
2519
 
2385
- # A rootless contract's input without ParamsWrapper's copy of the body,
2386
- # when Rails made one (see process_action). Removed rather than merely
2387
- # exempted from the unknown-keys check, because the client never sent that
2388
- # key: a contract that happens to declare a scalar or array field of the
2520
+ # FORM_KEYS bakes in "authenticity_token" — Rails' DEFAULT CSRF parameter
2521
+ # name — but `config.action_controller.request_forgery_protection_token`
2522
+ # lets an app rename it, and an app that does trips `unknown: :error` on
2523
+ # every ordinary form submission: exactly the bug FORM_KEYS exists to
2524
+ # prevent, just spelled with the app's own key instead of the default one.
2525
+ # The configured name isn't knowable at class-load time (it can vary per
2526
+ # controller, and Rails may not have finished initializing yet), so it's
2527
+ # read fresh here off the live controller instead of folded into a frozen
2528
+ # constant. `request_forgery_protection_token` comes from
2529
+ # ActionController::RequestForgeryProtection, included by ActionController
2530
+ # ::Base; a plain params duck or a standalone Contract has no such method
2531
+ # and exempts nothing beyond FORM_KEYS's own "authenticity_token".
2532
+ def permittable_configured_csrf_key
2533
+ return nil unless respond_to?(:request_forgery_protection_token)
2534
+
2535
+ token = request_forgery_protection_token
2536
+ token && token.to_s
2537
+ end
2538
+
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
2389
2544
  # wrapper's name (`optional :feedback, :string` on FeedbackController)
2390
2545
  # would otherwise validate Rails' copy of the whole body as that field — a
2391
- # false 422 invalid_type for a well-formed request.
2392
- #
2393
- # Kept, though, when the contract declares that key as a hash container (a
2394
- # nested block or :json): that rootless contract is reading the copy ON
2395
- # PURPOSE, a root: spelled as a field, and it worked that way before the
2396
- # copy was ever dropped — dropping it would turn every such request into
2397
- # `user missing`. Top level only, where the copy lives; a rooted contract
2398
- # reads the copy as its root, which is exactly what ParamsWrapper is for.
2399
- # What is checked changes, not what monitor mode hands back: its raw
2400
- # pass-through still carries the copy, as the pre-contract app's params did.
2401
- 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)
2402
2551
  key = @permittable_wrapper_key
2403
- return source unless key
2404
- 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
2405
2554
 
2406
- 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
2407
2572
  end
2408
2573
 
2409
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.9.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-27 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